diff --git a/DESCRIPTION b/DESCRIPTION index 72c81edb..782bc407 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -113,6 +113,7 @@ Collate: 'tool-env.R' 'tool-files-edit.R' 'tool-files-list.R' + 'tool-files-patch.R' 'tool-files-read.R' 'tool-files-replace.R' 'tool-files-search.R' diff --git a/NAMESPACE b/NAMESPACE index 2e21a8f0..ffb8da3d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -47,6 +47,7 @@ export(btw_tool_files_code_search) export(btw_tool_files_edit) export(btw_tool_files_list) export(btw_tool_files_list_files) +export(btw_tool_files_patch) export(btw_tool_files_read) export(btw_tool_files_read_text_file) export(btw_tool_files_replace) diff --git a/NEWS.md b/NEWS.md index 9606f968..7ef7c764 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## New features +* Added `btw_tool_files_patch()`, a new files tool that applies a structured diff-style patch envelope to make coordinated changes across multiple files in a single call. One patch can add, update, delete, and rename files atomically: all operations are validated before any file is written, so a partial failure leaves the working tree untouched (#190). + * Added two new commands to the `btw` CLI: `btw app` to launch a `btw_app()` session in the current working directory and `btw skills install` to install skills from the terminal. * Added `btw help` to the `btw` CLI, which prints the `r-btw-cli` skill — a usage guide designed for AI agents. diff --git a/R/tool-files-patch.R b/R/tool-files-patch.R new file mode 100644 index 00000000..e910c749 --- /dev/null +++ b/R/tool-files-patch.R @@ -0,0 +1,547 @@ +#' Tool: Apply a patch to files +#' +#' @description +#' Applies a structured diff-like patch envelope to one or more files. Unlike +#' [btw_tool_files_edit()] (which requires hashline references from a prior +#' read) or [btw_tool_files_replace()] (which requires exact strings), the +#' patch tool uses context-matching hunks so models can produce edits without +#' first reading the file. +#' +#' A single patch envelope can add, update, delete, and move files atomically: +#' either all operations succeed or none are applied. +#' +#' ## Patch syntax +#' +#' A patch is a text envelope that begins with `*** Begin Patch` and ends with +#' `*** End Patch`. Inside the envelope, each operation starts with a header: +#' +#' ** Begin Patch +#' ** Add File: docs/example.md +#' Hello +#' World +#' ** Update File: src/main.py +#' @@@ +#' context line +#' old line +#' new line +#' ** Delete File: old.txt +#' ** Update File: src/old.ts +#' ** Move to: src/new.ts +#' @@@ +#' context line +#' export const oldName = 1 +#' export const newName = 1 +#' ** End Patch +#' +#' ### Headers +#' +#' * `*** Add File: ` -- create a new file (must not exist). +#' * `*** Update File: ` -- modify an existing file. +#' * `*** Delete File: ` -- remove an existing file. +#' * `*** Move to: ` -- sub-header inside an `Update File` block; renames +#' the file to `` after applying any hunks. Destination must not exist. +#' +#' ### Hunk lines (inside `Update File`) +#' +#' * `@@` -- hunk boundary; any trailing text on this line is informational and +#' ignored. +#' * ` ` (space prefix) -- context line that must match the file exactly. +#' * `-` -- line to delete; must match the file exactly at this position. +#' * `+` -- line to insert. +#' +#' Every hunk must include at least one context or delete line to anchor the +#' edit; pure-insert hunks are rejected. Use `*** Add File` for new files. +#' +#' ### Add File body +#' +#' All body lines under `*** Add File` must start with `+`; the file content is +#' the text after each `+`. +#' +#' @param patch The full patch text in the wire format described below. Must +#' begin with `*** Begin Patch` and end with `*** End Patch`. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a summary of the operations applied. +#' +#' @seealso [btw_tool_files_edit()] for hashline-based targeted edits, +#' [btw_tool_files_replace()] for exact find-and-replace edits. +#' +#' @family files tools +#' @export +btw_tool_files_patch <- function(patch, `_intent`) {} + +btw_tool_files_patch_impl <- function(patch) { + ops <- parse_patch(patch) + validate_patch_ops(ops) + results <- apply_patch_ops(ops) + + n <- length(ops) + lines <- vapply( + ops, + function(op) { + switch( + op$op, + "add" = paste0(" - Added: ", op$path), + "delete" = paste0(" - Deleted: ", op$path), + "update" = if (!is.null(op$move_to)) { + paste0(" - Moved: ", op$path, " -> ", op$move_to) + } else { + paste0(" - Updated: ", op$path) + } + ) + }, + character(1) + ) + + value <- paste( + c( + sprintf("Applied patch: %d operation%s.", n, if (n != 1) "s" else ""), + lines + ), + collapse = "\n" + ) + + display_md <- paste( + c( + "**Patch**", + md_code_block("diff", patch), + "", + "**Results**", + value + ), + collapse = "\n" + ) + + # Use BtwToolResult rather than BtwFileDiffToolResult: a patch can touch + # multiple files, so the single-file diff viewer shape doesn't apply cleanly. + btw_tool_result( + value, + display = list( + markdown = display_md, + show_request = FALSE, + icon = tool_icon("file-save") + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_files_patch", + group = "files", + tool = function() { + ellmer::tool( + btw_tool_files_patch_impl, + name = "btw_tool_files_patch", + description = r"---(Apply a patch envelope that adds, updates, deletes, or moves files atomically. + +WHEN TO USE: +Use this tool when you want to make structured edits without reading the file first, +or when a single operation must touch multiple files atomically. Context lines in hunks +anchor the edit location so no prior hashline read is needed. + +WIRE FORMAT: + *** Begin Patch + *** Add File: docs/example.md + +Hello + +World + *** Update File: src/main.py + @@ + -print("Hi") + +print("Hello") + *** Delete File: old.txt + *** Update File: src/old.ts + *** Move to: src/new.ts + @@ + -export const oldName = 1 + +export const newName = 1 + *** End Patch + +HEADERS: +- "*** Begin Patch" / "*** End Patch" -- required envelope +- "*** Add File: " -- create a new file (must not exist) +- "*** Update File: " -- modify an existing file (must exist) +- "*** Delete File: " -- remove a file (must exist) +- "*** Move to: " -- sub-header inside Update File; rename destination + +HUNK LINES (inside Update File): +- "@@" -- hunk boundary (trailing text is informational, ignored) +- " " -- context line: must match the file exactly (space prefix) +- "-" -- delete this line +- "+" -- insert this line + +ADD FILE BODY: +Every body line must start with "+"; content follows the "+". + +ATOMICITY: +All operations are validated (hunk matching, path checks, filesystem preconditions) +before any file is written. If any operation fails, no changes are applied. + +NOTES: +- Context and delete lines must match the file exactly (case-sensitive, whitespace-significant). +- Each element of `content` in hunks is one line; do not include trailing newlines in the patch. +- Paths must be relative to the current working directory. + )---", + annotations = ellmer::tool_annotations( + title = "Patch Files", + read_only_hint = FALSE, + open_world_hint = FALSE, + idempotent_hint = FALSE, + btw_can_register = function() TRUE + ), + convert = FALSE, + arguments = list( + patch = ellmer::type_string( + "The full patch envelope as a single string. Must start with '*** Begin Patch' and end with '*** End Patch'. Lines are separated by newlines." + ) + ) + ) + } +) + +# --- Patch tool helpers --- + +# parse_patch: parse a patch string into a list of operation objects. +# Each op is: list(op, path, move_to, hunks, lines) as described in the spec. +# Hunk ops use a flat tagged-line list: list(list(type=..., line=...)) -- +# this makes both matching and applying straightforward without separate decomposition. +parse_patch <- function(patch) { + raw_lines <- strsplit(patch, "\n", fixed = TRUE)[[1]] + + state <- "outside" + ops <- list() + cur_op <- NULL + cur_hunk <- NULL + i <- 0L + + finalize_hunk <- function() { + if (!is.null(cur_hunk)) { + has_anchor <- any(vapply( + cur_hunk$ops, + function(o) o$type %in% c("context", "delete"), + logical(1) + )) + if (!has_anchor) { + cli::cli_abort(c( + "Line {cur_hunk$start_line}: hunk has no context or delete lines to anchor the insertion.", + i = "Add at least one context line (prefix ' ') or use '*** Add File' for new files." + )) + } + cur_op$hunks[[length(cur_op$hunks) + 1L]] <<- cur_hunk + cur_hunk <<- NULL + } + } + + finalize_op <- function() { + if (!is.null(cur_op)) { + finalize_hunk() + ops[[length(ops) + 1L]] <<- cur_op + cur_op <<- NULL + } + } + + for (raw_line in raw_lines) { + i <- i + 1L + + if (state == "outside") { + if (raw_line == "*** Begin Patch") { + state <- "inside" + } else if (startsWith(raw_line, "***")) { + cli::cli_abort( + "Line {i}: expected '*** Begin Patch' but got {.val {raw_line}}." + ) + } + next + } + + if (state == "done") { + next + } + + # Inside envelope + if (raw_line == "*** End Patch") { + finalize_op() + state <- "done" + next + } + + if (startsWith(raw_line, "*** Add File: ")) { + finalize_op() + path <- substring(raw_line, nchar("*** Add File: ") + 1L) + cur_op <- list(op = "add", path = path, lines = character()) + state <- "add" + next + } + + if (startsWith(raw_line, "*** Update File: ")) { + finalize_op() + path <- substring(raw_line, nchar("*** Update File: ") + 1L) + cur_op <- list(op = "update", path = path, move_to = NULL, hunks = list()) + state <- "update" + next + } + + if (startsWith(raw_line, "*** Delete File: ")) { + finalize_op() + path <- substring(raw_line, nchar("*** Delete File: ") + 1L) + cur_op <- list(op = "delete", path = path) + state <- "delete" + next + } + + if (startsWith(raw_line, "*** Move to: ")) { + if (state != "update") { + cli::cli_abort( + "Line {i}: '*** Move to:' is only valid inside an Update File block." + ) + } + cur_op$move_to <- substring(raw_line, nchar("*** Move to: ") + 1L) + next + } + + if (startsWith(raw_line, "***")) { + cli::cli_abort( + "Line {i}: unknown patch header {.val {raw_line}}." + ) + } + + if (state == "add") { + if (!startsWith(raw_line, "+")) { + cli::cli_abort( + "Line {i}: Add File body lines must start with '+', got {.val {raw_line}}." + ) + } + cur_op$lines <- c(cur_op$lines, substring(raw_line, 2L)) + next + } + + if (state == "delete") { + cli::cli_abort( + "Line {i}: unexpected content inside Delete File block: {.val {raw_line}}." + ) + } + + if (state == "update") { + if (startsWith(raw_line, "@@")) { + finalize_hunk() + cur_hunk <- list(ops = list(), start_line = i) + next + } + + if (is.null(cur_hunk)) { + cli::cli_abort( + "Line {i}: hunk line outside of '@@' boundary: {.val {raw_line}}." + ) + } + + if (startsWith(raw_line, " ")) { + cur_hunk$ops[[length(cur_hunk$ops) + 1L]] <- + list(type = "context", line = substring(raw_line, 2L)) + } else if (startsWith(raw_line, "-")) { + cur_hunk$ops[[length(cur_hunk$ops) + 1L]] <- + list(type = "delete", line = substring(raw_line, 2L)) + } else if (startsWith(raw_line, "+")) { + cur_hunk$ops[[length(cur_hunk$ops) + 1L]] <- + list(type = "insert", line = substring(raw_line, 2L)) + } else { + cli::cli_abort( + "Line {i}: invalid hunk line (expected ' ', '-', or '+'): {.val {raw_line}}." + ) + } + next + } + + # Should only reach here if state == "inside" with no active op + if (state == "inside") { + cli::cli_abort( + "Line {i}: content outside any operation block: {.val {raw_line}}." + ) + } + } + + if (state == "outside") { + cli::cli_abort("Patch is missing '*** Begin Patch'.") + } + if (state != "done") { + cli::cli_abort("Patch is missing '*** End Patch'.") + } + + ops +} + +validate_patch_ops <- function(ops) { + all_paths <- character() + + for (i in seq_along(ops)) { + op <- ops[[i]] + + if (!nzchar(op$path)) { + cli::cli_abort("Operation {i}: path must not be empty.") + } + check_path_within_current_wd(op$path) + + if (!is.null(op$move_to)) { + if (!nzchar(op$move_to)) { + cli::cli_abort("Operation {i}: move_to path must not be empty.") + } + check_path_within_current_wd(op$move_to) + } + + switch( + op$op, + "add" = { + if (fs::file_exists(op$path)) { + cli::cli_abort( + "Add File: {.path {op$path}} already exists. Use Update File to modify it." + ) + } + }, + "update" = { + if (!fs::file_exists(op$path)) { + cli::cli_abort( + "Update File: {.path {op$path}} does not exist." + ) + } + if (length(op$hunks) == 0L) { + cli::cli_abort( + "Update File: {.path {op$path}} has no hunks." + ) + } + if (!is.null(op$move_to) && fs::file_exists(op$move_to)) { + cli::cli_abort( + "Move to: {.path {op$move_to}} already exists." + ) + } + }, + "delete" = { + if (!fs::file_exists(op$path)) { + cli::cli_abort( + "Delete File: {.path {op$path}} does not exist." + ) + } + } + ) + + all_paths <- c(all_paths, op$path) + } +} + +# match_hunk: find where a hunk's context+delete lines appear in file_lines. +# Returns list(start = N, end = M) -- 1-based range covered by context+delete. +match_hunk <- function(hunk, file_lines, search_start, path) { + match_seq <- Filter( + function(x) x$type %in% c("context", "delete"), + hunk$ops + ) + + seq_len_val <- length(match_seq) + n_file <- length(file_lines) + last_start <- n_file - seq_len_val + 1L + + if (search_start <= last_start) { + for (start in seq(search_start, last_start)) { + matched <- TRUE + for (j in seq_len(seq_len_val)) { + if (file_lines[start + j - 1L] != match_seq[[j]]$line) { + matched <- FALSE + break + } + } + if (matched) { + return(list(start = start, end = start + seq_len_val - 1L)) + } + } + } + + cli::cli_abort(c( + "Hunk context not found in {.path {path}}.", + "i" = "First context/delete line: {.val {match_seq[[1L]]$line}}" + )) +} + +# apply_hunk: given matched range and hunk ops, produce replacement lines. +# Drops delete lines, keeps context lines, inserts insert lines. +apply_hunk <- function(hunk) { + out <- character() + for (item in hunk$ops) { + if (item$type == "context" || item$type == "insert") { + out <- c(out, item$line) + } + # delete: skip + } + out +} + +# apply_patch_ops: dry-run all ops, then commit atomically. +apply_patch_ops <- function(ops) { + staged <- vector("list", length(ops)) + + for (i in seq_along(ops)) { + op <- ops[[i]] + + if (op$op == "add") { + dir_path <- fs::path_dir(op$path) + staged[[i]] <- list( + type = "write", + path = op$path, + content = paste(op$lines, collapse = "\n"), + dir = if (dir_path != ".") dir_path else NULL + ) + } else if (op$op == "delete") { + staged[[i]] <- list(type = "delete", path = op$path) + } else if (op$op == "update") { + file_lines <- read_lines(op$path) + + # Match all hunks top-down, enforcing monotonic search_start + search_start <- 1L + matches <- vector("list", length(op$hunks)) + for (j in seq_along(op$hunks)) { + m <- match_hunk(op$hunks[[j]], file_lines, search_start, op$path) + matches[[j]] <- m + search_start <- m$end + 1L + } + + # Apply hunks bottom-up to avoid line-shift issues + new_lines <- file_lines + for (j in rev(seq_along(op$hunks))) { + replacement <- apply_hunk(op$hunks[[j]]) + m <- matches[[j]] + new_lines <- splice_lines(new_lines, m$start, m$end, replacement) + } + + new_content <- paste(new_lines, collapse = "\n") + dest_path <- op$move_to %||% op$path + + staged[[i]] <- list( + type = "update", + path = op$path, + dest_path = dest_path, + content = new_content, + move = !is.null(op$move_to) + ) + } + } + + # All dry-run succeeded -- commit to disk + for (s in staged) { + if (s$type == "write") { + if (!is.null(s$dir)) { + fs::dir_create(s$dir, recurse = TRUE) + } + write_file(s$content, s$path) + } else if (s$type == "delete") { + fs::file_delete(s$path) + } else if (s$type == "update") { + if (s$move) { + dest_dir <- fs::path_dir(s$dest_path) + if (dest_dir != ".") { + fs::dir_create(dest_dir, recurse = TRUE) + } + write_file(s$content, s$dest_path) + fs::file_delete(s$path) + } else { + write_file(s$content, s$path) + } + } + } + + invisible(staged) +} diff --git a/man/btw_tool_files_edit.Rd b/man/btw_tool_files_edit.Rd index 47442d4c..f4f9729c 100644 --- a/man/btw_tool_files_edit.Rd +++ b/man/btw_tool_files_edit.Rd @@ -117,6 +117,7 @@ don't require hashline references. Other files tools: \code{\link[=btw_tool_files_list]{btw_tool_files_list()}}, +\code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}}, \code{\link[=btw_tool_files_read]{btw_tool_files_read()}}, \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}}, \code{\link[=btw_tool_files_search]{btw_tool_files_search()}}, diff --git a/man/btw_tool_files_list.Rd b/man/btw_tool_files_list.Rd index c538298c..612aad0a 100644 --- a/man/btw_tool_files_list.Rd +++ b/man/btw_tool_files_list.Rd @@ -44,6 +44,7 @@ withr::with_tempdir({ \seealso{ Other files tools: \code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}}, +\code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}}, \code{\link[=btw_tool_files_read]{btw_tool_files_read()}}, \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}}, \code{\link[=btw_tool_files_search]{btw_tool_files_search()}}, diff --git a/man/btw_tool_files_patch.Rd b/man/btw_tool_files_patch.Rd new file mode 100644 index 00000000..c7accb4f --- /dev/null +++ b/man/btw_tool_files_patch.Rd @@ -0,0 +1,95 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-files-patch.R +\name{btw_tool_files_patch} +\alias{btw_tool_files_patch} +\title{Tool: Apply a patch to files} +\usage{ +btw_tool_files_patch(patch, `_intent` = "") +} +\arguments{ +\item{patch}{The full patch text in the wire format described below. Must +begin with \verb{*** Begin Patch} and end with \verb{*** End Patch}.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a summary of the operations applied. +} +\description{ +Applies a structured diff-like patch envelope to one or more files. Unlike +\code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}} (which requires hashline references from a prior +read) or \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}} (which requires exact strings), the +patch tool uses context-matching hunks so models can produce edits without +first reading the file. + +A single patch envelope can add, update, delete, and move files atomically: +either all operations succeed or none are applied. +\subsection{Patch syntax}{ + +A patch is a text envelope that begins with \verb{*** Begin Patch} and ends with +\verb{*** End Patch}. Inside the envelope, each operation starts with a header: + +\if{html}{\out{
}}\preformatted{** Begin Patch +** Add File: docs/example.md +Hello +World +** Update File: src/main.py +@@ +context line +old line +new line +** Delete File: old.txt +** Update File: src/old.ts +** Move to: src/new.ts +@@ +context line +export const oldName = 1 +export const newName = 1 +** End Patch +}\if{html}{\out{
}} +\subsection{Headers}{ +\itemize{ +\item \verb{*** Add File: } — create a new file (must not exist). +\item \verb{*** Update File: } — modify an existing file. +\item \verb{*** Delete File: } — remove an existing file. +\item \verb{*** Move to: } — sub-header inside an \verb{Update File} block; renames +the file to \verb{} after applying any hunks. Destination must not exist. +} +} + +\subsection{Hunk lines (inside \verb{Update File})}{ +\itemize{ +\item \code{@} — hunk boundary; any trailing text on this line is informational and +ignored. +\item \verb{ } (space prefix) — context line that must match the file exactly. +\item \verb{-} — line to delete; must match the file exactly at this position. +\item \verb{+} — line to insert. +} + +Every hunk must include at least one context or delete line to anchor the +edit; pure-insert hunks are rejected. Use \verb{*** Add File} for new files. +} + +\subsection{Add File body}{ + +All body lines under \verb{*** Add File} must start with \code{+}; the file content is +the text after each \code{+}. +} + +} +} +\seealso{ +\code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}} for hashline-based targeted edits, +\code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}} for exact find-and-replace edits. + +Other files tools: +\code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}}, +\code{\link[=btw_tool_files_list]{btw_tool_files_list()}}, +\code{\link[=btw_tool_files_read]{btw_tool_files_read()}}, +\code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}}, +\code{\link[=btw_tool_files_search]{btw_tool_files_search()}}, +\code{\link[=btw_tool_files_write]{btw_tool_files_write()}} +} +\concept{files tools} diff --git a/man/btw_tool_files_read.Rd b/man/btw_tool_files_read.Rd index 42bd8cf2..b624fe55 100644 --- a/man/btw_tool_files_read.Rd +++ b/man/btw_tool_files_read.Rd @@ -64,6 +64,7 @@ references, \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}} for Other files tools: \code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}}, \code{\link[=btw_tool_files_list]{btw_tool_files_list()}}, +\code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}}, \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}}, \code{\link[=btw_tool_files_search]{btw_tool_files_search()}}, \code{\link[=btw_tool_files_write]{btw_tool_files_write()}} diff --git a/man/btw_tool_files_replace.Rd b/man/btw_tool_files_replace.Rd index cde82032..c4a2af02 100644 --- a/man/btw_tool_files_replace.Rd +++ b/man/btw_tool_files_replace.Rd @@ -65,6 +65,7 @@ hashline references, \code{\link[=btw_tool_files_read]{btw_tool_files_read()}} f Other files tools: \code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}}, \code{\link[=btw_tool_files_list]{btw_tool_files_list()}}, +\code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}}, \code{\link[=btw_tool_files_read]{btw_tool_files_read()}}, \code{\link[=btw_tool_files_search]{btw_tool_files_search()}}, \code{\link[=btw_tool_files_write]{btw_tool_files_write()}} diff --git a/man/btw_tool_files_search.Rd b/man/btw_tool_files_search.Rd index 0e6ba65f..1b0bf89a 100644 --- a/man/btw_tool_files_search.Rd +++ b/man/btw_tool_files_search.Rd @@ -95,6 +95,7 @@ withr::with_tempdir({ Other files tools: \code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}}, \code{\link[=btw_tool_files_list]{btw_tool_files_list()}}, +\code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}}, \code{\link[=btw_tool_files_read]{btw_tool_files_read()}}, \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}}, \code{\link[=btw_tool_files_write]{btw_tool_files_write()}} diff --git a/man/btw_tool_files_write.Rd b/man/btw_tool_files_write.Rd index d383dafc..7c015822 100644 --- a/man/btw_tool_files_write.Rd +++ b/man/btw_tool_files_write.Rd @@ -34,6 +34,7 @@ withr::with_tempdir({ Other files tools: \code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}}, \code{\link[=btw_tool_files_list]{btw_tool_files_list()}}, +\code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}}, \code{\link[=btw_tool_files_read]{btw_tool_files_read()}}, \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}}, \code{\link[=btw_tool_files_search]{btw_tool_files_search()}} diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index d6568efc..2866d13a 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -69,6 +69,7 @@ this function have access to the tools: Name \tab Description \cr \code{\link[=btw_tool_files_edit]{btw_tool_files_edit()}} \tab Edit a text file using hashline references for precise, targeted modifications. \cr \code{\link[=btw_tool_files_list]{btw_tool_files_list()}} \tab List files or directories in the project. \cr + \code{\link[=btw_tool_files_patch]{btw_tool_files_patch()}} \tab Apply a patch envelope that adds, updates, deletes, or moves files atomically. \cr \code{\link[=btw_tool_files_read]{btw_tool_files_read()}} \tab Read the contents of a text file. \cr \code{\link[=btw_tool_files_replace]{btw_tool_files_replace()}} \tab Find and replace exact string occurrences in a text file. \cr \code{\link[=btw_tool_files_search]{btw_tool_files_search()}} \tab Search code files in the project. \cr diff --git a/tests/testthat/_snaps/tool-files-patch.md b/tests/testthat/_snaps/tool-files-patch.md new file mode 100644 index 00000000..64c56877 --- /dev/null +++ b/tests/testthat/_snaps/tool-files-patch.md @@ -0,0 +1,19 @@ +# btw_tool_files_patch_impl: success output for mixed add/update/delete + + Code + cat(result@value) + Output + Applied patch: 3 operations. + - Added: added.txt + - Updated: update_me.txt + - Deleted: delete_me.txt + +# btw_tool_files_patch_impl: failure output when hunk context not found + + Code + btw_tool_files_patch_impl(patch_str) + Condition + Error in `match_hunk()`: + ! Hunk context not found in 'target.txt'. + i First context/delete line: "nonexistent line" + diff --git a/tests/testthat/test-tool-files-patch.R b/tests/testthat/test-tool-files-patch.R new file mode 100644 index 00000000..9a652b71 --- /dev/null +++ b/tests/testthat/test-tool-files-patch.R @@ -0,0 +1,542 @@ +# ============================================================ +# parse_patch — Parser tests +# ============================================================ + +test_that("parse_patch: valid envelope with Add, Update, Delete ops", { + patch <- paste( + "*** Begin Patch", + "*** Add File: new.txt", + "+hello", + "+world", + "*** Update File: existing.txt", + "@@", + " context", + "-old line", + "+new line", + "*** Delete File: gone.txt", + "*** End Patch", + "", + sep = "\n" + ) + + ops <- parse_patch(patch) + expect_length(ops, 3L) + + expect_equal(ops[[1]]$op, "add") + expect_equal(ops[[1]]$path, "new.txt") + expect_equal(ops[[1]]$lines, c("hello", "world")) + + expect_equal(ops[[2]]$op, "update") + expect_equal(ops[[2]]$path, "existing.txt") + expect_null(ops[[2]]$move_to) + expect_length(ops[[2]]$hunks, 1L) + + expect_equal(ops[[3]]$op, "delete") + expect_equal(ops[[3]]$path, "gone.txt") +}) + +test_that("parse_patch: missing '*** Begin Patch' errors", { + patch <- paste( + "*** Update File: foo.txt", + "@@", + "+line", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "Begin Patch") +}) + +test_that("parse_patch: missing '*** End Patch' errors", { + patch <- paste( + "*** Begin Patch", + "*** Add File: new.txt", + "+line", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "End Patch") +}) + +test_that("parse_patch: unknown header inside envelope errors", { + patch <- paste( + "*** Begin Patch", + "*** Unknown Header: foo", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "unknown patch header") +}) + +test_that("parse_patch: Add File body with non-'+' line errors", { + patch <- paste( + "*** Begin Patch", + "*** Add File: new.txt", + "+good line", + "bad line", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "must start with '\\+'") +}) + +test_that("parse_patch: content lines outside any operation block errors", { + patch <- paste( + "*** Begin Patch", + "some stray content", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "content outside any operation block") +}) + +test_that("parse_patch: '*** Move to:' outside Update File block errors", { + patch <- paste( + "*** Begin Patch", + "*** Add File: new.txt", + "+line", + "*** Move to: other.txt", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "Move to.*only valid inside an Update File block") +}) + +test_that("parse_patch: Move to is captured inside Update File block", { + patch <- paste( + "*** Begin Patch", + "*** Update File: old.txt", + "*** Move to: new.txt", + "@@", + " context", + "-old", + "+new", + "*** End Patch", + "", + sep = "\n" + ) + + ops <- parse_patch(patch) + expect_length(ops, 1L) + expect_equal(ops[[1]]$op, "update") + expect_equal(ops[[1]]$path, "old.txt") + expect_equal(ops[[1]]$move_to, "new.txt") +}) + +test_that("parse_patch: hunk line outside @@ boundary errors", { + patch <- paste( + "*** Begin Patch", + "*** Update File: foo.txt", + " context line without @@", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(parse_patch(patch), "hunk line outside of '@@' boundary") +}) + +# ============================================================ +# validate_patch_ops — Filesystem validator tests +# ============================================================ + +test_that("validate_patch_ops: absolute path errors", { + ops <- list(list(op = "add", path = "/absolute/path.txt", lines = character())) + expect_error(validate_patch_ops(ops), "not allowed") +}) + +test_that("validate_patch_ops: '..' traversal errors", { + ops <- list(list(op = "add", path = "../evil.txt", lines = character())) + expect_error(validate_patch_ops(ops), "not allowed") +}) + +test_that("validate_patch_ops: empty path errors", { + ops <- list(list(op = "add", path = "", lines = character())) + expect_error(validate_patch_ops(ops), "path must not be empty") +}) + +test_that("validate_patch_ops: add over existing file errors", { + withr::local_dir(withr::local_tempdir()) + writeLines("existing", "exists.txt") + + ops <- list(list(op = "add", path = "exists.txt", lines = c("new"))) + expect_error(validate_patch_ops(ops), "already exists") +}) + +test_that("validate_patch_ops: update missing file errors", { + withr::local_dir(withr::local_tempdir()) + + ops <- list(list( + op = "update", + path = "missing.txt", + move_to = NULL, + hunks = list(list(ops = list(list(type = "context", line = "x")))) + )) + expect_error(validate_patch_ops(ops), "does not exist") +}) + +test_that("validate_patch_ops: update with no hunks errors", { + withr::local_dir(withr::local_tempdir()) + writeLines("content", "file.txt") + + ops <- list(list(op = "update", path = "file.txt", move_to = NULL, hunks = list())) + expect_error(validate_patch_ops(ops), "no hunks") +}) + +test_that("validate_patch_ops: delete missing file errors", { + withr::local_dir(withr::local_tempdir()) + + ops <- list(list(op = "delete", path = "missing.txt")) + expect_error(validate_patch_ops(ops), "does not exist") +}) + +test_that("validate_patch_ops: move to existing destination errors", { + withr::local_dir(withr::local_tempdir()) + writeLines("source", "source.txt") + writeLines("destination", "dest.txt") + + ops <- list(list( + op = "update", + path = "source.txt", + move_to = "dest.txt", + hunks = list(list(ops = list(list(type = "context", line = "source")))) + )) + expect_error(validate_patch_ops(ops), "already exists") +}) + +# ============================================================ +# match_hunk — Hunk matcher tests +# ============================================================ + +test_that("match_hunk: exact match finds correct range", { + hunk <- list(ops = list( + list(type = "context", line = "line2"), + list(type = "delete", line = "line3") + )) + file_lines <- c("line1", "line2", "line3", "line4") + + result <- match_hunk(hunk, file_lines, 1L, "test.txt") + expect_equal(result$start, 2L) + expect_equal(result$end, 3L) +}) + +test_that("match_hunk: hunk context not found errors", { + hunk <- list(ops = list( + list(type = "context", line = "not present") + )) + file_lines <- c("line1", "line2", "line3") + + expect_error( + match_hunk(hunk, file_lines, 1L, "test.txt"), + "Hunk context not found" + ) +}) + +test_that("match_hunk: hunk longer than file reports not-found cleanly", { + hunk <- list(ops = list( + list(type = "context", line = "a"), + list(type = "context", line = "b"), + list(type = "context", line = "c") + )) + + expect_error( + match_hunk(hunk, c("a", "b"), 1L, "test.txt"), + "Hunk context not found" + ) + expect_error( + match_hunk(hunk, character(), 1L, "test.txt"), + "Hunk context not found" + ) +}) + +test_that("parse_patch rejects pure-insert hunks (no context or delete)", { + patch <- paste( + "*** Begin Patch", + "*** Update File: x.txt", + "@@", + "+only an insert", + "*** End Patch", + "", + sep = "\n" + ) + expect_error(parse_patch(patch), "no context or delete lines") +}) + +test_that("parse_patch rejects pure-insert hunks even when other hunks are valid", { + patch <- paste( + "*** Begin Patch", + "*** Update File: x.txt", + "@@", + " a", + "-b", + "+B", + "@@", + "+trailing only", + "*** End Patch", + "", + sep = "\n" + ) + expect_error(parse_patch(patch), "no context or delete lines") +}) + +# ============================================================ +# apply_patch_ops — Applier tests +# ============================================================ + +test_that("apply_patch_ops: add file creates file with correct content", { + withr::local_dir(withr::local_tempdir()) + + ops <- list(list(op = "add", path = "new.txt", lines = c("hello", "world"))) + apply_patch_ops(ops) + + expect_true(fs::file_exists("new.txt")) + expect_equal(read_lines("new.txt"), c("hello", "world")) +}) + +test_that("apply_patch_ops: add file creates parent directories", { + withr::local_dir(withr::local_tempdir()) + + ops <- list(list(op = "add", path = "subdir/new.txt", lines = c("hello"))) + apply_patch_ops(ops) + + expect_true(fs::file_exists("subdir/new.txt")) + expect_equal(read_lines("subdir/new.txt"), "hello") +}) + +test_that("apply_patch_ops: update file applies single hunk", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("line1", "old line", "line3"), "test.txt") + + ops <- list(list( + op = "update", + path = "test.txt", + move_to = NULL, + hunks = list(list(ops = list( + list(type = "context", line = "line1"), + list(type = "delete", line = "old line"), + list(type = "insert", line = "new line") + ))) + )) + apply_patch_ops(ops) + + expect_equal(read_lines("test.txt"), c("line1", "new line", "line3")) +}) + +test_that("apply_patch_ops: update file applies multiple hunks", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("aaa", "bbb", "ccc", "ddd", "eee"), "test.txt") + + ops <- list(list( + op = "update", + path = "test.txt", + move_to = NULL, + hunks = list( + list(ops = list( + list(type = "delete", line = "aaa"), + list(type = "insert", line = "AAA") + )), + list(ops = list( + list(type = "delete", line = "eee"), + list(type = "insert", line = "EEE") + )) + ) + )) + apply_patch_ops(ops) + + expect_equal(read_lines("test.txt"), c("AAA", "bbb", "ccc", "ddd", "EEE")) +}) + +test_that("apply_patch_ops: delete file removes the file", { + withr::local_dir(withr::local_tempdir()) + writeLines("content", "todelete.txt") + + ops <- list(list(op = "delete", path = "todelete.txt")) + apply_patch_ops(ops) + + expect_false(fs::file_exists("todelete.txt")) +}) + +test_that("apply_patch_ops: move + update renames file and updates content", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("old content", "second line"), "old.txt") + + ops <- list(list( + op = "update", + path = "old.txt", + move_to = "new.txt", + hunks = list(list(ops = list( + list(type = "delete", line = "old content"), + list(type = "insert", line = "new content") + ))) + )) + apply_patch_ops(ops) + + expect_false(fs::file_exists("old.txt")) + expect_true(fs::file_exists("new.txt")) + expect_equal(read_lines("new.txt"), c("new content", "second line")) +}) + +test_that("apply_patch_ops: atomic failure — no filesystem changes when one op fails", { + withr::local_dir(withr::local_tempdir()) + + # good op: add a new file + # bad op: update a missing file (will fail during dry-run) + patch_str <- paste( + "*** Begin Patch", + "*** Add File: should_not_exist.txt", + "+created", + "*** Update File: does_not_exist.txt", + "@@", + "-missing line", + "+replacement", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(btw_tool_files_patch_impl(patch_str)) + expect_false(fs::file_exists("should_not_exist.txt")) +}) + +# ============================================================ +# btw_tool_files_patch_impl — Tool integration (snapshot) tests +# ============================================================ + +test_that("btw_tool_files_patch_impl: success output for mixed add/update/delete", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("context", "old line"), "update_me.txt") + writeLines("to remove", "delete_me.txt") + + patch_str <- paste( + "*** Begin Patch", + "*** Add File: added.txt", + "+new content", + "*** Update File: update_me.txt", + "@@", + " context", + "-old line", + "+new line", + "*** Delete File: delete_me.txt", + "*** End Patch", + "", + sep = "\n" + ) + + result <- btw_tool_files_patch_impl(patch_str) + expect_btw_tool_result(result, has_data = FALSE) + expect_snapshot(cat(result@value)) +}) + +test_that("btw_tool_files_patch_impl: failure output when hunk context not found", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("line1", "line2"), "target.txt") + + patch_str <- paste( + "*** Begin Patch", + "*** Update File: target.txt", + "@@", + "-nonexistent line", + "+replacement", + "*** End Patch", + "", + sep = "\n" + ) + + expect_snapshot( + btw_tool_files_patch_impl(patch_str), + error = TRUE + ) +}) + +test_that("btw_tool_files_patch_impl: success output for single add", { + withr::local_dir(withr::local_tempdir()) + + patch_str <- paste( + "*** Begin Patch", + "*** Add File: hello.txt", + "+Hello, world!", + "*** End Patch", + "", + sep = "\n" + ) + + result <- btw_tool_files_patch_impl(patch_str) + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "Applied patch: 1 operation\\.") + expect_match(result@value, "Added: hello\\.txt") + expect_equal(read_lines("hello.txt"), "Hello, world!") +}) + +test_that("btw_tool_files_patch_impl: success output for move + update", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("const oldName = 1"), "old.ts") + + patch_str <- paste( + "*** Begin Patch", + "*** Update File: old.ts", + "*** Move to: new.ts", + "@@", + "-const oldName = 1", + "+const newName = 1", + "*** End Patch", + "", + sep = "\n" + ) + + result <- btw_tool_files_patch_impl(patch_str) + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "Moved: old\\.ts") + expect_match(result@value, "new\\.ts") + expect_false(fs::file_exists("old.ts")) + expect_equal(read_lines("new.ts"), "const newName = 1") +}) + +test_that("btw_tool_files_patch_impl: multi-file patch applies all ops", { + withr::local_dir(withr::local_tempdir()) + writeLines(c("file1 line1", "file1 line2"), "file1.txt") + writeLines(c("file2 line1", "file2 line2"), "file2.txt") + + patch_str <- paste( + "*** Begin Patch", + "*** Update File: file1.txt", + "@@", + "-file1 line1", + "+file1 updated", + "*** Update File: file2.txt", + "@@", + "-file2 line2", + "+file2 updated", + "*** End Patch", + "", + sep = "\n" + ) + + result <- btw_tool_files_patch_impl(patch_str) + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "Applied patch: 2 operations\\.") + expect_equal(read_lines("file1.txt"), c("file1 updated", "file1 line2")) + expect_equal(read_lines("file2.txt"), c("file2 line1", "file2 updated")) +}) + +test_that("btw_tool_files_patch_impl: rejects paths outside working directory", { + patch_str <- paste( + "*** Begin Patch", + "*** Add File: ../evil.txt", + "+hacked", + "*** End Patch", + "", + sep = "\n" + ) + + expect_error(btw_tool_files_patch_impl(patch_str), "not allowed") +})