From 0ac2bca7d7c5cbcf5f749e4214c36c03b8023639 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 08:56:49 -0400 Subject: [PATCH 01/11] test: add scoped SQLite database helper --- tests/testthat/helpers.R | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index 0fc3f839..d68cdbcc 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -212,6 +212,23 @@ local_btw_md <- function(project = NULL, user = NULL, .env = caller_env()) { ) } +local_btw_db <- function(.env = caller_env()) { + path <- fs::file_temp(ext = "sqlite3") + paths <- c(paste0(path, c("-wal", "-shm")), path) + + withr::defer( + fs::file_delete(paths[fs::file_exists(paths)]), + envir = .env + ) + + local_mocked_bindings( + btw_db_path = function() path, + .env = .env + ) + + path +} + # shinychat >= 0.5.0 (a.k.a. the dev version leading up to it) serializes tool # cards as wire blocks; earlier versions return a static `` tag. shinychat_wire_blocks <- function() { From bbadaacafc89b4df203d4104b9094b5c9dfe81f3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 08:59:25 -0400 Subject: [PATCH 02/11] feat: store chat history in SQLite --- R/btw_chat_history_store.R | 196 ++++++++++--------- tests/testthat/test-btw_chat_history_store.R | 51 ++++- 2 files changed, 147 insertions(+), 100 deletions(-) diff --git a/R/btw_chat_history_store.R b/R/btw_chat_history_store.R index f54562a6..e75a8a7c 100644 --- a/R/btw_chat_history_store.R +++ b/R/btw_chat_history_store.R @@ -1,102 +1,67 @@ # nocov start -# Persistent conversation history for btw_app(). When duckdb is installed, -# shinychat's chat history is stored in a single database in the btw user -# cache directory, keyed by project directory -# (btw_app_history_project_dir()). +# Persistent conversation history for btw_app(). When RSQLite is installed, +# shinychat's chat history is stored in the shared btw database, keyed by +# project directory (btw_app_history_project_dir()). -btw_chat_history_db_path <- function() { - path_btw_cache("chat_history.duckdb") -} - -# Connect to a duckdb database, retrying while the file is locked by another -# process (duckdb allows only one writer process at a time). The connection -# and its driver are closed when the calling frame exits, even on error. -btw_duckdb_connect <- function( - path, - timeout = 10, - wait = 0.5, - max_delay = 2, - .envir = parent.frame() -) { - fs::dir_create(fs::path_dir(path)) - - deadline <- Sys.time() + timeout - delay <- wait - - repeat { - drv <- NULL - connected <- tryCatch( - { - drv <- duckdb::duckdb(dbdir = path) - list(con = DBI::dbConnect(drv), drv = drv) - }, - error = function(err) { - if (!is.null(drv)) { - duckdb::duckdb_shutdown(drv) - } - err - } - ) - - if (!inherits(connected, "error")) { - break - } - - if (Sys.time() >= deadline) { - cli::cli_abort(c( - "Could not connect to {.path {path}} within {timeout} seconds.", - "i" = "The database may be locked by another process." - )) - } - - Sys.sleep(delay) - delay <- min(delay * 2, max_delay) +btw_history_retention_days <- function() { + value <- Sys.getenv("BTW_HISTORY_RETENTION_DAYS", unset = "") + if (!nzchar(value)) { + return(365L) } - withr::defer( - { - DBI::dbDisconnect(connected$con) - duckdb::duckdb_shutdown(connected$drv) - }, - envir = .envir - ) + if (!grepl("^-?[0-9]+$", value)) { + return(365L) + } - connected$con -} + value <- suppressWarnings(as.integer(value)) + if (is.na(value) || value < -1L) { + return(365L) + } -btw_duckdb_init <- function(con) { - DBI::dbExecute( - con, - "CREATE TABLE IF NOT EXISTS conversations ( - scope VARCHAR NOT NULL, - chat_id VARCHAR NOT NULL, - id VARCHAR NOT NULL, - title VARCHAR, - created_at VARCHAR, - updated_at VARCHAR, - size_bytes DOUBLE, - data VARCHAR, - PRIMARY KEY (scope, chat_id, id) - )" - ) - invisible(NULL) + value } -btw_conversation_store_duckdb <- R6::R6Class( - "btw_conversation_store_duckdb", +btw_conversation_store_sqlite <- R6::R6Class( + "btw_conversation_store_sqlite", inherit = shinychat_conversation_store(), private = list( db_path = NULL, + retention_days = NULL, # Run `fn(con)` on a fresh connection to the store database, degrading # gracefully (warn + `fallback`) if the database can't be reached. - with_db = function(op, fallback, fn) { + with_db = function(op, fallback, fn, retry_busy = FALSE) { tryCatch( { - con <- btw_duckdb_connect(private$db_path) - btw_duckdb_init(con) - fn(con) + retries <- if (retry_busy) 3L else 0L + attempt <- 0L + + repeat { + run <- function() { + con <- btw_db_open(private$db_path, .envir = environment()) + fn(con) + } + result <- tryCatch( + run(), + error = identity + ) + + if (!inherits(result, "error")) { + return(result) + } + + attempt <- attempt + 1L + if ( + !retry_busy || + !private$is_sqlite_busy(result) || + attempt > retries + ) { + stop(result) + } + + Sys.sleep(0.2 * 2 ^ (attempt - 1L)) + } }, error = function(err) { cli::cli_warn(c( @@ -106,11 +71,41 @@ btw_conversation_store_duckdb <- R6::R6Class( fallback } ) + }, + + is_sqlite_busy = function(err) { + message <- conditionMessage(err) + grepl("SQLITE_BUSY", message, fixed = TRUE, ignore.case = TRUE) || + grepl( + "\\b(database|database table|database schema) (is )?locked\\b", + message, + ignore.case = TRUE, + perl = TRUE + ) + }, + + prune = function(con) { + if (private$retention_days < 0L) { + return(invisible(NULL)) + } + + cutoff <- format( + Sys.time() - private$retention_days * 24 * 60 * 60, + "%Y-%m-%dT%H:%M:%SZ", + tz = "UTC" + ) + DBI::dbExecute( + con, + "DELETE FROM conversations WHERE updated_at < ?", + params = list(cutoff) + ) + invisible(NULL) } ), public = list( initialize = function(db_path = NULL) { - private$db_path <- db_path %||% btw_chat_history_db_path() + private$db_path <- db_path %||% btw_db_path() + private$retention_days <- btw_history_retention_days() }, list = function(partition) { @@ -162,10 +157,14 @@ btw_conversation_store_duckdb <- R6::R6Class( }, put = function(partition, record) { + if (private$retention_days == 0L) { + return(invisible(NULL)) + } + data <- jsonlite::serializeJSON(record, digits = 17) size_bytes <- as.double(nchar(data, type = "bytes")) - private$with_db( + invisible(private$with_db( "save", invisible(NULL), function(con) { @@ -191,13 +190,15 @@ btw_conversation_store_duckdb <- R6::R6Class( data ) ) + private$prune(con) invisible(NULL) - } - ) + }, + retry_busy = TRUE + )) }, delete = function(partition, id) { - private$with_db( + invisible(private$with_db( "delete", invisible(NULL), function(con) { @@ -208,8 +209,9 @@ btw_conversation_store_duckdb <- R6::R6Class( params = list(partition$scope, partition$chat_id, id) ) invisible(NULL) - } - ) + }, + retry_busy = TRUE + )) } ) ) @@ -235,20 +237,28 @@ btw_app_history_project_dir <- function(path_btw = NULL) { } btw_app_history_options <- function(path_btw = NULL) { - if (!rlang::is_installed("duckdb")) { + retention_days <- btw_history_retention_days() + if ( + retention_days == 0L || + !rlang::is_installed("RSQLite") + ) { + if (retention_days == 0L) { + return(TRUE) + } + cli::cli_inform( c( "Chat history: conversations aren't saved between {.fn btw_app} sessions.", - "i" = "Install the {.pkg duckdb} R package to keep your conversation history in a local database: {.code install.packages(\"duckdb\")}." + "i" = "Install the {.pkg RSQLite} R package to keep your conversation history in a local database: {.code install.packages(\"RSQLite\")}." ), .frequency = "once", - .frequency_id = "btw_app_history_duckdb" + .frequency_id = "btw_app_history_sqlite" ) return(TRUE) } shinychat_history_options( - store = btw_conversation_store_duckdb$new(), + store = btw_conversation_store_sqlite$new(), scope = btw_app_history_project_dir(path_btw) ) } diff --git a/tests/testthat/test-btw_chat_history_store.R b/tests/testthat/test-btw_chat_history_store.R index fa68e0b0..9cb2d980 100644 --- a/tests/testthat/test-btw_chat_history_store.R +++ b/tests/testthat/test-btw_chat_history_store.R @@ -1,4 +1,4 @@ -skip_if_not_installed("duckdb") +skip_if_not_installed("RSQLite") skip_if_no_shinychat_v05() history_record <- function( @@ -25,13 +25,12 @@ history_record <- function( ) } -new_history_store <- function() { - db <- fs::file_temp(ext = "duckdb") - withr::defer(fs::file_delete(db[fs::file_exists(db)])) - btw:::btw_conversation_store_duckdb$new(db) +new_history_store <- function(.env = caller_env()) { + db <- local_btw_db(.env) + btw:::btw_conversation_store_sqlite$new(db) } -test_that("btw_conversation_store_duckdb round-trips a record", { +test_that("btw_conversation_store_sqlite round-trips a record", { store <- new_history_store() partition <- list(chat_id = "chat", scope = "project-a") record <- history_record("abc123") @@ -111,6 +110,44 @@ test_that("delete() removes a conversation and is a no-op for missing ids", { expect_invisible(store$delete(partition, "never-existed")) }) +test_that("put() prunes conversations older than the retention period", { + withr::local_envvar(BTW_HISTORY_RETENTION_DAYS = "1") + store <- new_history_store() + partition <- list(chat_id = "chat", scope = "project-a") + old <- format( + Sys.time() - 2 * 24 * 60 * 60, + "%Y-%m-%dT%H:%M:%SZ", + tz = "UTC" + ) + now <- format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC") + + store$put(partition, history_record("old", created_at = old, updated_at = old)) + store$put(partition, history_record("new", created_at = now, updated_at = now)) + + expect_null(store$get(partition, "old")) + expect_identical(store$get(partition, "new")$id, "new") +}) + +test_that("history retention can disable recording or retain forever", { + partition <- list(chat_id = "chat", scope = "project-a") + old <- format( + Sys.time() - 366 * 24 * 60 * 60, + "%Y-%m-%dT%H:%M:%SZ", + tz = "UTC" + ) + + withr::local_envvar(BTW_HISTORY_RETENTION_DAYS = "0") + expect_true(btw:::btw_app_history_options()) + disabled <- new_history_store() + disabled$put(partition, history_record("disabled")) + expect_null(disabled$get(partition, "disabled")) + + withr::local_envvar(BTW_HISTORY_RETENTION_DAYS = "-1") + forever <- new_history_store() + forever$put(partition, history_record("old", created_at = old, updated_at = old)) + expect_identical(forever$get(partition, "old")$id, "old") +}) + test_that("search() and total_size() work via the base class defaults", { store <- new_history_store() partition <- list(chat_id = "chat", scope = "project-a") @@ -140,7 +177,7 @@ test_that("btw_app_history_project_dir() resolves from path_btw", { ) }) -test_that("btw_app_history_options() returns duckdb-backed history options", { +test_that("btw_app_history_options() returns SQLite-backed history options", { options <- btw:::btw_app_history_options() expect_true(inherits(options$store, "ConversationStore")) expect_identical( From cd081a1cb0e533c8851882664b87a82b5737cb29 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 09:12:10 -0400 Subject: [PATCH 03/11] feat: replace DuckDB with SQLite storage --- DESCRIPTION | 3 +- NEWS.md | 4 + R/btw_chat_history_store.R | 123 ++++- R/btw_client_app.R | 6 +- R/btw_client_app_v05.R | 18 + R/btw_project_db.R | 97 ++++ R/tool-files-search.R | 501 +++++++++++++++---- R/tool-pkg-src.R | 2 +- man/btw_client.Rd | 6 +- tests/testthat/test-btw-project-db.R | 60 +++ tests/testthat/test-btw_chat_history_store.R | 64 +++ tests/testthat/test-tool-files-search.R | 197 ++++++++ tests/testthat/test-tool-pkg-src.R | 12 +- 13 files changed, 988 insertions(+), 105 deletions(-) create mode 100644 R/btw_project_db.R create mode 100644 tests/testthat/test-btw-project-db.R create mode 100644 tests/testthat/test-tool-files-search.R diff --git a/DESCRIPTION b/DESCRIPTION index 03880950..67309c52 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -63,7 +63,6 @@ Suggests: desc, devtools, diffviewer, - duckdb, evaluate, fansi, gert, @@ -76,6 +75,7 @@ Suggests: Rapp (>= 0.3.0), renv, roxygen2, + RSQLite, shiny, shinychat (>= 0.5.0), testthat (>= 3.0.0), @@ -101,6 +101,7 @@ Collate: 'btw_client_app_legacy.R' 'btw_client_app_slash_commands.R' 'btw_client_app_v05.R' + 'btw_project_db.R' 'btw_task.R' 'btw_this.R' 'cli.R' diff --git a/NEWS.md b/NEWS.md index 8abe2e58..7f38f7ba 100644 --- a/NEWS.md +++ b/NEWS.md @@ -30,6 +30,10 @@ ## Other changes +* Local conversation history and project code-search indexes now use RSQLite, + replacing DuckDB. Persistent data is stored in btw's user cache and separated + by project; install RSQLite to enable this storage. + * btw now requires ellmer (>= 0.4.2). The `set_model()` compatibility shim was removed, and `client_get_models()` now delegates to ellmer's `models_list()` generic, replacing a bespoke per-provider dispatch table (#214). # btw 1.4.0 diff --git a/R/btw_chat_history_store.R b/R/btw_chat_history_store.R index e75a8a7c..cbe3de92 100644 --- a/R/btw_chat_history_store.R +++ b/R/btw_chat_history_store.R @@ -259,8 +259,129 @@ btw_app_history_options <- function(path_btw = NULL) { shinychat_history_options( store = btw_conversation_store_sqlite$new(), - scope = btw_app_history_project_dir(path_btw) + scope = btw_app_history_project_dir(path_btw), + restore_mode = "none" ) } +btw_project_active_conversation_id <- function( + project_path, + db_path = btw_db_path() +) { + con <- btw_db_open(db_path, .envir = environment()) + project <- DBI::dbGetQuery( + con, + "SELECT active_conversation_id FROM projects WHERE path = ?", + params = list(project_path) + ) + + if (nrow(project) == 0) { + return(NULL) + } + + project$active_conversation_id[[1]] +} + +btw_project_set_active_conversation_id <- function( + project_path, + conversation_id, + db_path = btw_db_path() +) { + con <- btw_db_open(db_path, .envir = environment()) + DBI::dbExecute( + con, + paste0( + "INSERT INTO projects (path, active_conversation_id, last_opened_at) ", + "VALUES (?, ?, ?) ", + "ON CONFLICT(path) DO UPDATE SET ", + "active_conversation_id = excluded.active_conversation_id, ", + "last_opened_at = excluded.last_opened_at" + ), + params = list( + project_path, + conversation_id, + format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC") + ) + ) + invisible(NULL) +} + +btw_app_history_restore_project_conversation <- function( + controller, + project_path +) { + conversation_id <- tryCatch( + btw_project_active_conversation_id(project_path), + error = function(err) { + cli::cli_warn(c( + "Failed to read the active chat history conversation.", + conditionMessage(err) + )) + NULL + } + ) + if (is.null(conversation_id)) { + return(FALSE) + } + + tryCatch( + { + record <- controller$get_record(controller$partition, conversation_id) + if (is.null(record)) { + return(FALSE) + } + controller$switch_to(conversation_id) + TRUE + }, + error = function(err) { + cli::cli_warn(c( + "Failed to restore the active chat history conversation.", + conditionMessage(err) + )) + FALSE + } + ) +} + +btw_app_history_use_project_pointer <- function( + chat, + controller, + project_path +) { + settled <- FALSE + previous_on_settled <- controller$on_settled + + controller$on_settled <- function(restored) { + if (!settled) { + settled <<- TRUE + btw_app_history_restore_project_conversation(controller, project_path) + } + + if (!is.null(previous_on_settled)) { + previous_on_settled(restored) + } + } + + shiny::observeEvent( + chat$history$conversation_id(), + ignoreNULL = TRUE, + { + tryCatch( + btw_project_set_active_conversation_id( + project_path, + chat$history$conversation_id() + ), + error = function(err) { + cli::cli_warn(c( + "Failed to save the active chat history conversation.", + conditionMessage(err) + )) + } + ) + } + ) + + invisible(NULL) +} + # nocov end diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 223bf710..5a2147af 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -12,11 +12,11 @@ #' @section Conversation History: #' With shinychat >= 0.5.0, conversations in `btw_app()` are kept in a chat #' history that you can revisit from the app's history drawer. When the -#' [duckdb](https://duckdb.r-dbi.org/) package is installed, conversations -#' are stored in a single database in btw's user cache directory +#' [RSQLite](https://rsqlite.r-dbi.org/) package is installed, conversations +#' are stored locally in a SQLite database in btw's user cache directory #' (`tools::R_user_dir("btw", "cache")`), keyed by project directory: the #' directory containing the closest `DESCRIPTION` or `.git` marker, or the -#' working directory otherwise. If duckdb is not installed, shinychat's +#' working directory otherwise. If RSQLite is not installed, shinychat's #' default history storage is used instead. #' @export btw_app <- function( diff --git a/R/btw_client_app_v05.R b/R/btw_client_app_v05.R index 04c2ea1d..f8a9bb9f 100644 --- a/R/btw_client_app_v05.R +++ b/R/btw_client_app_v05.R @@ -62,6 +62,24 @@ btw_app_shell_page_chat <- function(state) { if (length(state$messages)) { app_replay_messages(chat, state$messages) } + + if ( + inherits(state$history, "chat_history_config") && + identical(state$history$restore_mode, "none") && + is.character(state$history$scope) + ) { + controller <- btw_shinychat_050_object( + "get_session_chat_bookmark_info" + )(shiny::getDefaultReactiveDomain(), "chat.history-controller") + if (!is.null(controller)) { + btw_app_history_use_project_pointer( + chat, + controller, + state$history$scope + ) + } + } + chat } diff --git a/R/btw_project_db.R b/R/btw_project_db.R new file mode 100644 index 00000000..bac5a1e4 --- /dev/null +++ b/R/btw_project_db.R @@ -0,0 +1,97 @@ +btw_db_path <- function() { + path_btw_cache("btw.sqlite3") +} + +btw_db_open <- function(path = btw_db_path(), .envir = parent.frame()) { + fs::dir_create(fs::path_dir(path)) + + con <- DBI::dbConnect(RSQLite::SQLite(), dbname = path) + withr::defer(DBI::dbDisconnect(con), envir = .envir) + + DBI::dbExecute(con, "PRAGMA busy_timeout = 2000") + btw_db_enable_wal(con) + btw_db_initialize(con) + + con +} + +btw_db_enable_wal <- function(con) { + mode <- DBI::dbGetQuery(con, "PRAGMA journal_mode = WAL") + identical(tolower(mode$journal_mode[[1]]), "wal") +} + +btw_db_initialize <- function(con) { + DBI::dbExecute( + con, + "CREATE TABLE IF NOT EXISTS projects ( + path TEXT PRIMARY KEY, + label TEXT, + active_conversation_id TEXT, + last_opened_at TEXT + )" + ) + DBI::dbExecute( + con, + "CREATE TABLE IF NOT EXISTS state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )" + ) + DBI::dbExecute( + con, + "INSERT OR IGNORE INTO state (key, value) VALUES (?, ?)", + params = list("schema_version", "1") + ) + DBI::dbExecute( + con, + "CREATE TABLE IF NOT EXISTS conversations ( + scope TEXT NOT NULL, + chat_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT, + created_at TEXT, + updated_at TEXT, + size_bytes REAL, + data TEXT, + PRIMARY KEY (scope, chat_id, id) + )" + ) + DBI::dbExecute( + con, + "CREATE TABLE IF NOT EXISTS files ( + project_path TEXT NOT NULL, + path TEXT NOT NULL, + mtime REAL, + size INTEGER, + PRIMARY KEY (project_path, path) + )" + ) + + invisible(NULL) +} + +btw_db_search_table_name <- function(project_path) { + project_path <- fs::path_norm(fs::path_abs(fs::path_expand(project_path))) + paste0("search_", substr(rlang::hash(project_path), 1, 16)) +} + +btw_db_create_search_table <- function(con, project_path) { + table_name <- btw_db_search_table_name(project_path) + table_identifier <- as.character(DBI::dbQuoteIdentifier(con, table_name)) + + DBI::dbExecute( + con, + paste0( + "CREATE VIRTUAL TABLE IF NOT EXISTS ", + table_identifier, + " USING fts5( + path UNINDEXED, + line UNINDEXED, + content, + tokenize='trigram case_sensitive 0' + )" + ) + ) + + table_name +} diff --git a/R/tool-files-search.R b/R/tool-files-search.R index fe853164..3bb49519 100644 --- a/R/tool-files-search.R +++ b/R/tool-files-search.R @@ -85,31 +85,53 @@ btw_tool_files_search_factory <- function( check_path_exists(path) if (restrict_to_wd) { check_path_within_current_wd(path) - path <- fs::path_rel(path) } check_character(extensions, allow_na = FALSE) check_character(exclusions, allow_na = FALSE, allow_null = TRUE) - .db_create_local_file_index <- function() { - if (identical(Sys.getenv("TESTTHAT"), "true")) { - # In testthat, we don't want to create a DuckDB database - return(NULL) - } - rlang::check_installed("DBI") - rlang::check_installed("duckdb") + rlang::check_installed("DBI") + rlang::check_installed("RSQLite") + + project_path <- fs::path_norm(fs::path_abs(fs::path_expand(path))) + state <- new.env(parent = emptyenv()) + state$con <- NULL + state$last_refresh <- NULL + state$db_path <- if (restrict_to_wd) btw_db_path() else fs::file_temp(ext = "sqlite3") + reg.finalizer( + state, + function(state) { + if (!is.null(state$con) && DBI::dbIsValid(state$con)) { + DBI::dbDisconnect(state$con) + } + }, + onexit = TRUE + ) - withr::local_options(cli.progress_handlers_only = "cli") - cli::cli_progress_step( - "Indexing files in {.path {fs::path_real(path)}} for code search" - ) - db_create_local_files( - path, - extensions, - exclusions, - restrict_to_wd = restrict_to_wd + if (!restrict_to_wd) { + cleanup_envir <- parent.frame() + withr::defer( + { + if (!is.null(state$con) && DBI::dbIsValid(state$con)) { + DBI::dbDisconnect(state$con) + } + db_files <- c( + state$db_path, + paste0(state$db_path, "-wal"), + paste0(state$db_path, "-shm") + ) + fs::file_delete(db_files[fs::file_exists(db_files)]) + }, + envir = cleanup_envir ) } + get_con <- function() { + if (is.null(state$con) || !DBI::dbIsValid(state$con)) { + state$con <- btw_db_open(state$db_path, .envir = state) + } + state$con + } + function( term, limit = 100, @@ -122,36 +144,53 @@ btw_tool_files_search_factory <- function( check_bool(case_sensitive) check_bool(use_regex) - compare_fn <- if (use_regex) "regexp_matches" else "contains" - haystack <- if (case_sensitive) "content" else "lower(content)" - needle <- if (case_sensitive) term else tolower(term) - - if (show_lines) { - # truncate the content to 100 characters to avoid overly large results - query_select <- "filename, size, last_modified, SUBSTR(content, 1, 100) AS content, line" - query_group_by <- "" - query_order_by <- "ORDER BY last_modified DESC, filename ASC, line ASC" - } else { - # size and last_modified are not in the GROUP BY clause - query_select <- "filename, MAX(size) AS size, MAX(last_modified) AS last_modified, COUNT(*) AS n_matching_lines" - query_group_by <- "GROUP BY filename" - query_order_by <- "ORDER BY n_matching_lines DESC, last_modified DESC" - } - - con <- .db_create_local_file_index() - - query <- sprintf( - "SELECT %s FROM code_file_lines WHERE %s(%s, ?) %s %s LIMIT ?", - query_select, - compare_fn, - haystack, - query_group_by, - query_order_by + con <- get_con() + table_name <- btw_db_search_table_name(project_path) + table_identifier <- as.character(DBI::dbQuoteIdentifier(con, table_name)) + + tryCatch( + btw_search_refresh_index( + con = con, + project_path = project_path, + table_name = table_name, + path = project_path, + extensions = extensions, + exclusions = exclusions, + restrict_to_wd = restrict_to_wd + ), + error = function(cnd) { + if (!btw_search_is_busy(cnd)) { + stop(cnd) + } + btw_search_warn_busy() + } ) + state$last_refresh <- Sys.time() max_display <- 20L - - res <- DBI::dbGetQuery(con, query, params = list(needle, limit)) + res <- if (!DBI::dbExistsTable(con, table_name)) { + btw_search_empty_results(show_lines) + } else if (use_regex) { + btw_search_regex( + con, + table_identifier, + project_path, + term, + limit, + case_sensitive, + show_lines + ) + } else { + btw_search_literal( + con, + table_identifier, + project_path, + term, + limit, + case_sensitive, + show_lines + ) + } res$size <- fs::as_fs_bytes(res$size) BtwToolResult( @@ -209,7 +248,7 @@ Use the `btw_tool_files_read` tool, if available, to read the full content of a open_world_hint = FALSE, idempotent_hint = FALSE, btw_can_register = function() { - is_installed("duckdb") && is_installed("DBI") + is_installed("RSQLite") && is_installed("DBI") } ), arguments = list( @@ -238,20 +277,12 @@ Use the `btw_tool_files_read` tool, if available, to read the full content of a } ) -db_create_local_files <- function( - path = getwd(), - extensions = files_search_extensions(), - exclusions = files_search_exclusions(), - restrict_to_wd = TRUE +btw_search_candidate_files <- function( + path, + extensions, + exclusions, + restrict_to_wd ) { - if (restrict_to_wd) { - check_path_within_current_wd(path) - } - check_character(extensions, allow_na = FALSE) - check_character(exclusions, allow_na = FALSE, allow_null = TRUE) - - # Validate extensions contain only letters, numbers, underscore, or dash - # and no regex-special characters. Throw if invalid. bad_ext <- !grepl("^[[:alnum:]_-]+$", extensions) if (any(bad_ext)) { cli::cli_abort(c( @@ -261,8 +292,6 @@ db_create_local_files <- function( } ext_regex <- sprintf("[.](%s)$", paste(extensions, collapse = "|")) - - # Enumerate files with regex filter all_files <- fs::dir_ls( path, recurse = TRUE, @@ -290,41 +319,339 @@ db_create_local_files <- function( )) } - con <- DBI::dbConnect(duckdb::duckdb()) - DBI::dbExecute(con, "INSTALL fts") - DBI::dbExecute(con, "LOAD fts") + if (length(all_files) == 0) { + return(data.frame( + path = character(), + source_path = character(), + mtime = numeric(), + size = numeric() + )) + } + + info <- fs::file_info(all_files) + output_paths <- if (restrict_to_wd) { + fs::path_rel(all_files, start = getwd()) + } else { + fs::path_norm(all_files) + } + data.frame( + path = output_paths, + source_path = fs::path_norm(all_files), + mtime = as.numeric(info$modification_time), + size = as.numeric(info$size), + stringsAsFactors = FALSE + ) +} - # Create the `code_files` table - query <- sprintf( - "CREATE TABLE code_files AS SELECT filename, size, last_modified, content FROM read_text([%s]);", - paste(sprintf("'%s'", all_files), collapse = ", ") +btw_search_refresh_index <- function( + con, + project_path, + table_name, + path, + extensions, + exclusions, + restrict_to_wd +) { + btw_search_prune_stale_indexes(con) + table_name <- btw_db_create_search_table(con, project_path) + table_identifier <- as.character(DBI::dbQuoteIdentifier(con, table_name)) + candidates <- btw_search_candidate_files( + path, + extensions, + exclusions, + restrict_to_wd + ) + ledger <- DBI::dbGetQuery( + con, + "SELECT path, mtime, size FROM files WHERE project_path = ?", + params = list(project_path) ) - DBI::dbExecute(con, query) + removed <- setdiff(ledger$path, candidates$path) + changed <- candidates[!candidates$path %in% ledger$path, , drop = FALSE] + shared <- candidates[candidates$path %in% ledger$path, , drop = FALSE] + if (nrow(shared) > 0) { + old <- ledger[match(shared$path, ledger$path), , drop = FALSE] + changed <- rbind( + changed, + shared[shared$mtime != old$mtime | shared$size != old$size, , drop = FALSE] + ) + } + deleted_files <- length(removed) + sum(changed$path %in% ledger$path) + + if (length(removed) > 0) { + btw_search_delete_files(con, table_identifier, project_path, removed) + } + if (nrow(changed) > 0) { + chunks <- split( + seq_len(nrow(changed)), + ceiling(seq_len(nrow(changed)) / 500) + ) + for (chunk in chunks) { + btw_search_refresh_files( + con, + table_identifier, + project_path, + changed[chunk, , drop = FALSE] + ) + } + } + + # Avoid FTS5 segment maintenance for ordinary, small file changes. + optimize_after_deletions <- 100L + if (deleted_files > optimize_after_deletions) { + btw_search_optimize(con, table_identifier) + } - # Create the `code_file_lines` table DBI::dbExecute( con, - " -CREATE TABLE code_file_lines AS -SELECT - code_files.filename, - size, - last_modified, - unnest(lines) as content, - generate_subscripts(lines, 1) as line -FROM - code_files -JOIN ( - SELECT - filename, - STRING_SPLIT(REGEXP_REPLACE(content, '\r\n|\r', '\n'), '\n') as lines - FROM code_files - ) as code_lines -ON code_files.filename = code_lines.filename;" + paste0( + "INSERT INTO projects (path, label, last_opened_at) VALUES (?, ?, ?) ", + "ON CONFLICT(path) DO UPDATE SET ", + "label = excluded.label, last_opened_at = excluded.last_opened_at" + ), + params = list( + project_path, + fs::path_file(project_path), + format(Sys.time(), tz = "UTC", usetz = TRUE) + ) ) - invisible(con) + invisible(NULL) +} + +btw_search_optimize <- function(con, table_identifier) { + DBI::dbExecute( + con, + paste0( + "INSERT INTO ", table_identifier, + "(", table_identifier, ") VALUES (?)" + ), + params = list("optimize") + ) +} + +btw_search_delete_files <- function(con, table_identifier, project_path, paths) { + DBI::dbWithTransaction(con, { + for (path in paths) { + DBI::dbExecute( + con, + paste0("DELETE FROM ", table_identifier, " WHERE path = ?"), + params = list(path) + ) + DBI::dbExecute( + con, + "DELETE FROM files WHERE project_path = ? AND path = ?", + params = list(project_path, path) + ) + } + }) +} + +btw_search_refresh_files <- function( + con, + table_identifier, + project_path, + files +) { + DBI::dbWithTransaction(con, { + for (i in seq_len(nrow(files))) { + path <- files$path[[i]] + source_path <- files$source_path[[i]] + lines <- tryCatch(brio::read_lines(source_path), error = function(cnd) NULL) + + DBI::dbExecute( + con, + paste0("DELETE FROM ", table_identifier, " WHERE path = ?"), + params = list(path) + ) + DBI::dbExecute( + con, + "DELETE FROM files WHERE project_path = ? AND path = ?", + params = list(project_path, path) + ) + if (is.null(lines) || !fs::file_exists(source_path)) { + next + } + + if (length(lines) > 0) { + for (line in seq_along(lines)) { + DBI::dbExecute( + con, + paste0( + "INSERT INTO ", table_identifier, + " (path, line, content) VALUES (?, ?, ?)" + ), + params = list(path, line, lines[[line]]) + ) + } + } + DBI::dbExecute( + con, + "INSERT INTO files (project_path, path, mtime, size) VALUES (?, ?, ?, ?)", + params = list(project_path, path, files$mtime[[i]], files$size[[i]]) + ) + } + }) +} + +btw_search_literal <- function( + con, + table_identifier, + project_path, + term, + limit, + case_sensitive, + show_lines +) { + if (nchar(term, type = "chars") < 3) { + where <- if (case_sensitive) { + "instr(search.content, ?) > 0" + } else { + "instr(lower(search.content), lower(?)) > 0" + } + params <- list(project_path, term) + } else { + where <- if (case_sensitive) { + "search.content MATCH ? AND instr(search.content, ?) > 0" + } else { + "search.content MATCH ? AND instr(lower(search.content), lower(?)) > 0" + } + params <- list(project_path, btw_search_match_term(term), term) + } + lines <- btw_search_query(con, table_identifier, where, params) + if (nchar(term, type = "chars") >= 3 && nrow(lines) == 0) { + fallback_where <- if (case_sensitive) { + "instr(search.content, ?) > 0" + } else { + "instr(lower(search.content), lower(?)) > 0" + } + lines <- btw_search_query( + con, + table_identifier, + fallback_where, + list(project_path, term) + ) + } + btw_search_shape_results(lines, limit, show_lines) +} + +btw_search_regex <- function( + con, + table_identifier, + project_path, + term, + limit, + case_sensitive, + show_lines +) { + lines <- btw_search_query(con, table_identifier, "1 = 1", list(project_path)) + matched <- grepl(term, lines$content, perl = TRUE, ignore.case = !case_sensitive) + btw_search_shape_results(lines[matched, , drop = FALSE], limit, show_lines) +} + +btw_search_query <- function(con, table_identifier, where, params) { + DBI::dbGetQuery( + con, + paste0( + "SELECT search.path AS filename, files.size, files.mtime AS last_modified, ", + "search.content, search.line ", + "FROM ", table_identifier, " AS search ", + "JOIN files ON files.project_path = ? AND files.path = search.path ", + "WHERE ", where + ), + params = params + ) +} + +btw_search_shape_results <- function(lines, limit, show_lines) { + if (nrow(lines) == 0) { + return(btw_search_empty_results(show_lines)) + } + lines <- lines[order(-lines$last_modified, lines$filename, lines$line), , drop = FALSE] + + if (show_lines) { + lines$content <- substr(lines$content, 1, 100) + return(utils::head(lines, limit)) + } + + counts <- stats::aggregate(line ~ filename, data = lines, FUN = length) + names(counts)[[2]] <- "n_matching_lines" + metadata <- lines[!duplicated(lines$filename), c("filename", "size", "last_modified")] + res <- merge(counts, metadata, by = "filename", sort = FALSE) + res <- res[order(-res$n_matching_lines, -res$last_modified), , drop = FALSE] + utils::head(res[, c("filename", "size", "last_modified", "n_matching_lines")], limit) +} + +btw_search_empty_results <- function(show_lines) { + if (show_lines) { + return(data.frame( + filename = character(), + size = numeric(), + last_modified = numeric(), + content = character(), + line = integer() + )) + } + data.frame( + filename = character(), + size = numeric(), + last_modified = numeric(), + n_matching_lines = integer() + ) +} + +btw_search_match_term <- function(term) { + paste0('"', gsub('"', '""', term, fixed = TRUE), '"') +} + +btw_search_is_busy <- function(cnd) { + grepl("SQLITE_BUSY|database is locked|database is busy", conditionMessage(cnd)) +} + +btw_search_warn_busy <- local({ + warned <- FALSE + function() { + if (!warned) { + warned <<- TRUE + cli::cli_warn( + "Code-search index is busy; searching the last available index instead." + ) + } + } +}) + +btw_search_prune_stale_indexes <- function(con) { + days <- suppressWarnings(as.numeric(Sys.getenv("BTW_SEARCH_INDEX_STALE_DAYS", "30"))) + if (length(days) != 1 || is.na(days)) { + days <- 30 + } + cutoff <- format(Sys.time() - days * 24 * 60 * 60, tz = "UTC", usetz = TRUE) + stale <- DBI::dbGetQuery( + con, + "SELECT path FROM projects WHERE last_opened_at IS NOT NULL AND last_opened_at < ?", + params = list(cutoff) + )$path + if (length(stale) == 0) { + return(invisible(NULL)) + } + + for (project_path in stale) { + table_name <- btw_db_search_table_name(project_path) + if (DBI::dbExistsTable(con, table_name)) { + DBI::dbExecute( + con, + paste0("DROP TABLE ", DBI::dbQuoteIdentifier(con, table_name)) + ) + } + DBI::dbExecute( + con, + "DELETE FROM files WHERE project_path = ?", + params = list(project_path) + ) + } + + invisible(NULL) } files_search_extensions <- function() { diff --git a/R/tool-pkg-src.R b/R/tool-pkg-src.R index 799ce217..ffa65218 100644 --- a/R/tool-pkg-src.R +++ b/R/tool-pkg-src.R @@ -681,7 +681,7 @@ btw_tool_pkg_src_search_impl <- function( cli::cli_abort("`terms` must contain at least one search term.") } - check_installed("duckdb") + check_installed("RSQLite") check_installed("DBI") path_info <- btw_pkg_src_path_info(package) diff --git a/man/btw_client.Rd b/man/btw_client.Rd index 71361150..dd43bf47 100644 --- a/man/btw_client.Rd +++ b/man/btw_client.Rd @@ -163,11 +163,11 @@ chat With shinychat >= 0.5.0, conversations in \code{btw_app()} are kept in a chat history that you can revisit from the app's history drawer. When the -\href{https://duckdb.r-dbi.org/}{duckdb} package is installed, conversations -are stored in a single database in btw's user cache directory +\href{https://rsqlite.r-dbi.org/}{RSQLite} package is installed, conversations +are stored locally in a SQLite database in btw's user cache directory (\code{tools::R_user_dir("btw", "cache")}), keyed by project directory: the directory containing the closest \code{DESCRIPTION} or \code{.git} marker, or the -working directory otherwise. If duckdb is not installed, shinychat's +working directory otherwise. If RSQLite is not installed, shinychat's default history storage is used instead. } diff --git a/tests/testthat/test-btw-project-db.R b/tests/testthat/test-btw-project-db.R new file mode 100644 index 00000000..b3203708 --- /dev/null +++ b/tests/testthat/test-btw-project-db.R @@ -0,0 +1,60 @@ +skip_if_not_installed("DBI") +skip_if_not_installed("RSQLite") + +test_that("btw_db_open() initializes the shared schema", { + dir <- withr::local_tempdir() + path <- fs::path(dir, "cache", "btw.sqlite3") + con <- btw:::btw_db_open(path, .envir = environment()) + + expect_true(fs::file_exists(path)) + + tables <- DBI::dbGetQuery( + con, + "SELECT name FROM sqlite_master WHERE type = 'table'" + )$name + expect_setequal( + c("projects", "state", "conversations", "files"), + intersect(tables, c("projects", "state", "conversations", "files")) + ) + expect_identical( + DBI::dbGetQuery( + con, + "SELECT value FROM state WHERE key = ?", + params = list("schema_version") + )$value, + "1" + ) +}) + +test_that("per-project search table names are deterministic and safe", { + project <- "project; DROP TABLE projects; --" + table_name <- btw:::btw_db_search_table_name(project) + + expect_identical(table_name, btw:::btw_db_search_table_name(project)) + expect_match(table_name, "^search_[a-f0-9]{16}$") + + dir <- withr::local_tempdir() + con <- btw:::btw_db_open(fs::path(dir, "btw.sqlite3"), .envir = environment()) + expect_identical(btw:::btw_db_create_search_table(con, project), table_name) + + sql <- DBI::dbGetQuery( + con, + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", + params = list(table_name) + )$sql + expect_match(sql, "tokenize='trigram case_sensitive 0'", fixed = TRUE) +}) + +test_that("btw_db_open() disconnects when its calling frame exits", { + dir <- withr::local_tempdir() + path <- fs::path(dir, "nested", "btw.sqlite3") + + open_database <- function(path) { + con <- btw:::btw_db_open(path) + expect_true(DBI::dbIsValid(con)) + con + } + + con <- open_database(path) + expect_false(DBI::dbIsValid(con)) +}) diff --git a/tests/testthat/test-btw_chat_history_store.R b/tests/testthat/test-btw_chat_history_store.R index 9cb2d980..5a92fbb1 100644 --- a/tests/testthat/test-btw_chat_history_store.R +++ b/tests/testthat/test-btw_chat_history_store.R @@ -184,4 +184,68 @@ test_that("btw_app_history_options() returns SQLite-backed history options", { options$scope, btw:::btw_app_history_project_dir() ) + expect_identical(options$restore_mode, "none") +}) + +test_that("project active conversation helpers store a project pointer", { + db <- local_btw_db() + project <- "/path/to/project" + + expect_null(btw:::btw_project_active_conversation_id(project)) + expect_invisible( + btw:::btw_project_set_active_conversation_id(project, "conversation-a") + ) + expect_identical( + btw:::btw_project_active_conversation_id(project), + "conversation-a" + ) + + con <- btw_db_open(db, .envir = environment()) + project_row <- DBI::dbGetQuery( + con, + "SELECT path, active_conversation_id, last_opened_at FROM projects WHERE path = ?", + params = list(project) + ) + expect_identical(project_row$path, project) + expect_identical(project_row$active_conversation_id, "conversation-a") + expect_true(nzchar(project_row$last_opened_at)) +}) + +test_that("project pointer lifecycle restores once and follows local changes", { + local_btw_db() + project <- "/path/to/project" + btw:::btw_project_set_active_conversation_id(project, "saved") + + conversation_id <- shiny::reactiveVal(NULL) + restored <- character() + settled <- logical() + controller <- list2env(list( + partition = list(scope = project, chat_id = "chat"), + on_settled = function(value) settled <<- c(settled, value), + get_record = function(partition, id) { + if (identical(id, "saved")) list(id = id) else NULL + }, + switch_to = function(id) restored <<- c(restored, id) + )) + chat <- list(history = list(conversation_id = conversation_id)) + + shiny::testServer( + function(input, output, session) { + btw:::btw_app_history_use_project_pointer(chat, controller, project) + }, + { + session$flushReact() + controller$on_settled(FALSE) + controller$on_settled(FALSE) + expect_identical(restored, "saved") + expect_identical(settled, c(FALSE, FALSE)) + + conversation_id("new-conversation") + session$flushReact() + expect_identical( + btw:::btw_project_active_conversation_id(project), + "new-conversation" + ) + } + ) }) diff --git a/tests/testthat/test-tool-files-search.R b/tests/testthat/test-tool-files-search.R new file mode 100644 index 00000000..434ddfe9 --- /dev/null +++ b/tests/testthat/test-tool-files-search.R @@ -0,0 +1,197 @@ +test_that("file search persists its index and refreshes changed files", { + local_btw_db() + search_dir <- fs::path_temp("search-persistence") + fs::dir_create(search_dir) + withr::local_dir(search_dir) + writeLines("first_search_term <- TRUE", "code.R") + + first <- btw_tool_files_search_factory() + first_data <- jsonlite::fromJSON(S7::prop( + first("first_search_term", show_lines = TRUE), + "value" + )) + expect_equal(first_data$content, "first_search_term <- TRUE") + + con <- btw_db_open(.envir = environment()) + table_name <- btw_db_search_table_name(getwd()) + expect_true(DBI::dbExistsTable(con, table_name)) + expect_equal(DBI::dbGetQuery(con, "SELECT COUNT(*) AS n FROM files")$n, 1) + + writeLines("second_search_term <- TRUE", "code.R") + second_data <- jsonlite::fromJSON(S7::prop( + first("second_search_term", show_lines = TRUE), + "value" + )) + expect_equal(second_data$content, "second_search_term <- TRUE") + expect_equal( + length(jsonlite::fromJSON(S7::prop( + first("first_search_term", show_lines = TRUE), + "value" + ))), + 0 + ) +}) + +test_that("file search optimizes only after many indexed-file deletions", { + local_btw_db() + search_dir <- fs::path_temp("search-optimize") + fs::dir_create(search_dir) + withr::local_dir(search_dir) + writeLines("first_search_term <- TRUE", "code.R") + search <- btw_tool_files_search_factory() + + optimized <- 0L + local_mocked_bindings( + btw_search_optimize = function(...) { + optimized <<- optimized + 1L + }, + .package = "btw" + ) + + search("first_search_term") + writeLines("second_search_term <- TRUE", "code.R") + search("second_search_term") + expect_equal(optimized, 0L) + + paths <- fs::path(sprintf("code-%03d.R", seq_len(101L))) + purrr::walk(paths, writeLines, text = "many_search_terms <- TRUE") + search("many_search_terms") + fs::file_delete(paths) + search("many_search_terms") + expect_equal(optimized, 1L) +}) + +test_that("file search treats literal FTS syntax as literal text", { + local_btw_db() + search_dir <- fs::path_temp("search-literals") + fs::dir_create(search_dir) + withr::local_dir(search_dir) + writeLines(c("on.exit(foo)", "call <- c(1,2)", "quote <- 'a\"b'"), "code.R") + search <- btw_tool_files_search_factory() + + expect_equal( + nrow(jsonlite::fromJSON(S7::prop(search("on.exit", show_lines = TRUE), "value"))), + 1 + ) + expect_equal( + nrow(jsonlite::fromJSON(S7::prop(search("c(1,2)", show_lines = TRUE), "value"))), + 1 + ) + expect_equal( + nrow(jsonlite::fromJSON(S7::prop(search('a"b', show_lines = TRUE), "value"))), + 1 + ) +}) + +test_that("file search falls back for short terms and preserves case semantics", { + local_btw_db() + search_dir <- fs::path_temp("search-case") + fs::dir_create(search_dir) + withr::local_dir(search_dir) + writeLines(c("ab <- 1", "snake_case <- 2", "snakeCase <- 3"), "code.R") + search <- btw_tool_files_search_factory() + + expect_equal( + nrow(jsonlite::fromJSON(S7::prop(search("ab", show_lines = TRUE), "value"))), + 1 + ) + insensitive <- jsonlite::fromJSON(S7::prop( + search("SNAKE_CASE", case_sensitive = FALSE, show_lines = TRUE), + "value" + )) + expect_equal(insensitive$content, "snake_case <- 2") + expect_equal( + length(jsonlite::fromJSON(S7::prop( + search("SNAKE_CASE", case_sensitive = TRUE, show_lines = TRUE), + "value" + ))), + 0 + ) +}) + +test_that("file search applies regular expressions in R", { + local_btw_db() + search_dir <- fs::path_temp("search-regex") + fs::dir_create(search_dir) + withr::local_dir(search_dir) + writeLines(c("alpha_12 <- TRUE", "alpha_x <- FALSE"), "code.R") + search <- btw_tool_files_search_factory() + + data <- jsonlite::fromJSON(S7::prop( + search("^alpha_[0-9]+", use_regex = TRUE, show_lines = TRUE), + "value" + )) + expect_equal(data$content, "alpha_12 <- TRUE") +}) + +test_that("package-source factories use a temporary database", { + local_btw_db() + withr::local_tempdir() + source_dir <- fs::path_temp("package-source") + fs::dir_create(source_dir) + writeLines("temporary_source_term <- TRUE", fs::path(source_dir, "source.R")) + + search <- btw_tool_files_search_factory(source_dir, restrict_to_wd = FALSE) + data <- jsonlite::fromJSON(S7::prop( + search("temporary_source_term", show_lines = TRUE), + "value" + )) + expect_equal(data$content, "temporary_source_term <- TRUE") + + con <- btw_db_open(.envir = environment()) + expect_false(DBI::dbExistsTable(con, btw_db_search_table_name(source_dir))) + expect_equal(DBI::dbGetQuery(con, "SELECT COUNT(*) AS n FROM files")$n, 0) +}) + +test_that("stale search cleanup is limited to the idle project index", { + local_btw_db() + withr::local_envvar(BTW_SEARCH_INDEX_STALE_DAYS = "1") + con <- btw_db_open(.envir = environment()) + idle_path <- fs::path_temp("idle-project") + active_path <- fs::path_temp("active-project") + idle_table <- btw_db_create_search_table(con, idle_path) + active_table <- btw_db_create_search_table(con, active_path) + old <- format(Sys.time() - 2 * 24 * 60 * 60, tz = "UTC", usetz = TRUE) + now <- format(Sys.time(), tz = "UTC", usetz = TRUE) + + DBI::dbExecute( + con, + "INSERT INTO projects (path, label, last_opened_at) VALUES (?, ?, ?), (?, ?, ?)", + params = list(idle_path, "idle", old, active_path, "active", now) + ) + DBI::dbExecute( + con, + "INSERT INTO files (project_path, path, mtime, size) VALUES (?, ?, ?, ?), (?, ?, ?, ?)", + params = list(idle_path, "idle.R", 1, 1, active_path, "active.R", 1, 1) + ) + DBI::dbExecute( + con, + "INSERT INTO conversations (scope, chat_id, id, data) VALUES (?, ?, ?, ?)", + params = list("project", "chat", "conversation", "{}") + ) + + btw_search_prune_stale_indexes(con) + + expect_false(DBI::dbExistsTable(con, idle_table)) + expect_true(DBI::dbExistsTable(con, active_table)) + expect_equal( + DBI::dbGetQuery( + con, + "SELECT COUNT(*) AS n FROM files WHERE project_path = ?", + params = list(idle_path) + )$n, + 0 + ) + expect_equal( + DBI::dbGetQuery( + con, + "SELECT COUNT(*) AS n FROM files WHERE project_path = ?", + params = list(active_path) + )$n, + 1 + ) + expect_equal( + DBI::dbGetQuery(con, "SELECT COUNT(*) AS n FROM conversations")$n, + 1 + ) +}) diff --git a/tests/testthat/test-tool-pkg-src.R b/tests/testthat/test-tool-pkg-src.R index 9ad30fb6..31bce955 100644 --- a/tests/testthat/test-tool-pkg-src.R +++ b/tests/testthat/test-tool-pkg-src.R @@ -1,6 +1,3 @@ -duckdb_extensions <- withr::local_tempdir() -local_options(duckdb.home = duckdb_extensions) - # Test btw_tool_pkg_src_list_impl ---------------------------------------------- test_that("btw_tool_pkg_src_list_impl returns exported objects by default", { @@ -378,9 +375,8 @@ test_that("btw_tool_pkg_src_search_impl validates arguments", { }) test_that("btw_tool_pkg_src_search_impl searches materialized source for binary-installed packages", { - skip_if_not_installed("duckdb") + skip_if_not_installed("RSQLite") skip_if_not_installed("DBI") - withr::local_envvar(TESTTHAT = NA) result <- btw_tool_pkg_src_search_impl("tools", "file.path") @@ -412,9 +408,8 @@ test_that("btw_tool_pkg_src_search_impl searches materialized source for binary- test_that("btw_tool_pkg_src_search_impl preserves exact operator names", { skip_if_not_installed("vctrs") - skip_if_not_installed("duckdb") + skip_if_not_installed("RSQLite") skip_if_not_installed("DBI") - withr::local_envvar(TESTTHAT = NA) result <- btw_tool_pkg_src_search_impl("vctrs", "vec_cast(value, x)") data <- S7::prop(result, "extra")$data @@ -428,9 +423,8 @@ test_that("btw_tool_pkg_src_search_impl preserves exact operator names", { }) test_that("btw_tool_pkg_src_search_impl combines results across multiple terms", { - skip_if_not_installed("duckdb") + skip_if_not_installed("RSQLite") skip_if_not_installed("DBI") - withr::local_envvar(TESTTHAT = NA) result <- btw_tool_pkg_src_search_impl("tools", c("file.path", "toRd")) data <- S7::prop(result, "extra")$data From a3d63510a846b2690c4918188fdd47bda7a50bd5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 09:13:40 -0400 Subject: [PATCH 04/11] style: keep NEWS entry on one line --- NEWS.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index 7f38f7ba..74a21644 100644 --- a/NEWS.md +++ b/NEWS.md @@ -30,9 +30,7 @@ ## Other changes -* Local conversation history and project code-search indexes now use RSQLite, - replacing DuckDB. Persistent data is stored in btw's user cache and separated - by project; install RSQLite to enable this storage. +* Local conversation history and project code-search indexes now use RSQLite, replacing DuckDB. Persistent data is stored in btw's user cache and separated by project; install RSQLite to enable this storage. * btw now requires ellmer (>= 0.4.2). The `set_model()` compatibility shim was removed, and `client_get_models()` now delegates to ellmer's `models_list()` generic, replacing a bespoke per-provider dispatch table (#214). From 603cf880a52a5f55f4075e61c01feff7342956b5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 09:29:23 -0400 Subject: [PATCH 05/11] fix: lazy-load SQLite history store --- DESCRIPTION | 1 + R/btw_chat_history_store.R | 22 ++++++++++++-------- tests/testthat/test-btw_chat_history_store.R | 7 ++++++- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 67309c52..4360cdc9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -72,6 +72,7 @@ Suggests: pkgload, processx, ragg, + R6, Rapp (>= 0.3.0), renv, roxygen2, diff --git a/R/btw_chat_history_store.R b/R/btw_chat_history_store.R index cbe3de92..bc6481e9 100644 --- a/R/btw_chat_history_store.R +++ b/R/btw_chat_history_store.R @@ -22,10 +22,13 @@ btw_history_retention_days <- function() { value } -btw_conversation_store_sqlite <- R6::R6Class( - "btw_conversation_store_sqlite", - inherit = shinychat_conversation_store(), - private = list( +btw_conversation_store_sqlite <- function() { + rlang::check_installed("R6") + + R6::R6Class( + "btw_conversation_store_sqlite", + inherit = shinychat_conversation_store(), + private = list( db_path = NULL, retention_days = NULL, @@ -101,8 +104,8 @@ btw_conversation_store_sqlite <- R6::R6Class( ) invisible(NULL) } - ), - public = list( + ), + public = list( initialize = function(db_path = NULL) { private$db_path <- db_path %||% btw_db_path() private$retention_days <- btw_history_retention_days() @@ -213,11 +216,12 @@ btw_conversation_store_sqlite <- R6::R6Class( retry_busy = TRUE )) } + ) ) -) +} btw_app_history_project_dir <- function(path_btw = NULL) { - if (!is.null(path_btw)) { + if (!is.null(path_btw) && !identical(path_btw, FALSE)) { path <- fs::path_abs(fs::path_expand(path_btw)) if (fs::dir_exists(path)) { return(as.character(fs::path_norm(path))) @@ -258,7 +262,7 @@ btw_app_history_options <- function(path_btw = NULL) { } shinychat_history_options( - store = btw_conversation_store_sqlite$new(), + store = btw_conversation_store_sqlite()$new(), scope = btw_app_history_project_dir(path_btw), restore_mode = "none" ) diff --git a/tests/testthat/test-btw_chat_history_store.R b/tests/testthat/test-btw_chat_history_store.R index 5a92fbb1..c851631a 100644 --- a/tests/testthat/test-btw_chat_history_store.R +++ b/tests/testthat/test-btw_chat_history_store.R @@ -27,7 +27,7 @@ history_record <- function( new_history_store <- function(.env = caller_env()) { db <- local_btw_db(.env) - btw:::btw_conversation_store_sqlite$new(db) + btw:::btw_conversation_store_sqlite()$new(db) } test_that("btw_conversation_store_sqlite round-trips a record", { @@ -175,6 +175,11 @@ test_that("btw_app_history_project_dir() resolves from path_btw", { btw:::btw_app_history_project_dir(dir), as.character(fs::path_norm(fs::path_abs(dir))) ) + + expect_identical( + btw:::btw_app_history_project_dir(FALSE), + btw:::btw_app_history_project_dir() + ) }) test_that("btw_app_history_options() returns SQLite-backed history options", { From f996f870a112003339304c08053123e62cd88a5b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 09:37:10 -0400 Subject: [PATCH 06/11] test: close SQLite search connections --- tests/testthat/test-tool-files-search.R | 28 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/testthat/test-tool-files-search.R b/tests/testthat/test-tool-files-search.R index 434ddfe9..bf8d4474 100644 --- a/tests/testthat/test-tool-files-search.R +++ b/tests/testthat/test-tool-files-search.R @@ -1,3 +1,19 @@ +local_file_search <- function(..., .env = caller_env()) { + search <- btw_tool_files_search_factory(...) + state <- environment(search)$state + + withr::defer( + { + if (!is.null(state$con) && DBI::dbIsValid(state$con)) { + DBI::dbDisconnect(state$con) + } + }, + envir = .env + ) + + search +} + test_that("file search persists its index and refreshes changed files", { local_btw_db() search_dir <- fs::path_temp("search-persistence") @@ -5,7 +21,7 @@ test_that("file search persists its index and refreshes changed files", { withr::local_dir(search_dir) writeLines("first_search_term <- TRUE", "code.R") - first <- btw_tool_files_search_factory() + first <- local_file_search() first_data <- jsonlite::fromJSON(S7::prop( first("first_search_term", show_lines = TRUE), "value" @@ -38,7 +54,7 @@ test_that("file search optimizes only after many indexed-file deletions", { fs::dir_create(search_dir) withr::local_dir(search_dir) writeLines("first_search_term <- TRUE", "code.R") - search <- btw_tool_files_search_factory() + search <- local_file_search() optimized <- 0L local_mocked_bindings( @@ -67,7 +83,7 @@ test_that("file search treats literal FTS syntax as literal text", { fs::dir_create(search_dir) withr::local_dir(search_dir) writeLines(c("on.exit(foo)", "call <- c(1,2)", "quote <- 'a\"b'"), "code.R") - search <- btw_tool_files_search_factory() + search <- local_file_search() expect_equal( nrow(jsonlite::fromJSON(S7::prop(search("on.exit", show_lines = TRUE), "value"))), @@ -89,7 +105,7 @@ test_that("file search falls back for short terms and preserves case semantics", fs::dir_create(search_dir) withr::local_dir(search_dir) writeLines(c("ab <- 1", "snake_case <- 2", "snakeCase <- 3"), "code.R") - search <- btw_tool_files_search_factory() + search <- local_file_search() expect_equal( nrow(jsonlite::fromJSON(S7::prop(search("ab", show_lines = TRUE), "value"))), @@ -115,7 +131,7 @@ test_that("file search applies regular expressions in R", { fs::dir_create(search_dir) withr::local_dir(search_dir) writeLines(c("alpha_12 <- TRUE", "alpha_x <- FALSE"), "code.R") - search <- btw_tool_files_search_factory() + search <- local_file_search() data <- jsonlite::fromJSON(S7::prop( search("^alpha_[0-9]+", use_regex = TRUE, show_lines = TRUE), @@ -131,7 +147,7 @@ test_that("package-source factories use a temporary database", { fs::dir_create(source_dir) writeLines("temporary_source_term <- TRUE", fs::path(source_dir, "source.R")) - search <- btw_tool_files_search_factory(source_dir, restrict_to_wd = FALSE) + search <- local_file_search(source_dir, restrict_to_wd = FALSE) data <- jsonlite::fromJSON(S7::prop( search("temporary_source_term", show_lines = TRUE), "value" From 83ec06105f1713547131f8c2b5524bfe0f4b7ba8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 09:52:08 -0400 Subject: [PATCH 07/11] fix: require SQLite trigram support --- DESCRIPTION | 2 +- R/btw_project_db.R | 11 +++++++++-- R/btw_this.R | 4 ++-- R/tool-docs-news.R | 2 +- R/tool-env-df.R | 2 +- R/tool-files-search.R | 22 +++++++++++++++++----- R/tool-github.R | 4 ++-- R/tool-pkg-src.R | 6 +++--- tests/testthat/_snaps/tool-env-df.md | 3 +-- tests/testthat/test-btw-project-db.R | 21 ++++++++++++++++++++- tests/testthat/test-cli.R | 2 +- tests/testthat/test-tool-docs-news.R | 2 +- tests/testthat/test-tool-files-search.R | 18 ++++++++++++++++-- 13 files changed, 75 insertions(+), 24 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 4360cdc9..427f9b6b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -76,7 +76,7 @@ Suggests: Rapp (>= 0.3.0), renv, roxygen2, - RSQLite, + RSQLite (>= 2.2.2), shiny, shinychat (>= 0.5.0), testthat (>= 3.0.0), diff --git a/R/btw_project_db.R b/R/btw_project_db.R index bac5a1e4..4878e00e 100644 --- a/R/btw_project_db.R +++ b/R/btw_project_db.R @@ -27,9 +27,16 @@ btw_db_initialize <- function(con) { path TEXT PRIMARY KEY, label TEXT, active_conversation_id TEXT, - last_opened_at TEXT + last_opened_at TEXT, + search_indexed_at TEXT )" ) + if (!"search_indexed_at" %in% DBI::dbListFields(con, "projects")) { + DBI::dbExecute( + con, + "ALTER TABLE projects ADD COLUMN search_indexed_at TEXT" + ) + } DBI::dbExecute( con, "CREATE TABLE IF NOT EXISTS state ( @@ -40,7 +47,7 @@ btw_db_initialize <- function(con) { DBI::dbExecute( con, "INSERT OR IGNORE INTO state (key, value) VALUES (?, ?)", - params = list("schema_version", "1") + params = list("schema_version", "2") ) DBI::dbExecute( con, diff --git a/R/btw_this.R b/R/btw_this.R index e0665d67..9fbdd641 100644 --- a/R/btw_this.R +++ b/R/btw_this.R @@ -400,7 +400,7 @@ btw_this_help <- function(args) { } btw_this_git <- function(args) { - check_installed("gert") + rlang::check_installed("gert") # Try to get git info early to provide better error messages tryCatch( @@ -519,7 +519,7 @@ btw_this_github_pr <- function(args) { # - "owner/repo#123" # - "owner/repo 123" btw_this_github_item <- function(args, type = c("issue", "pr")) { - check_installed("gh") + rlang::check_installed("gh") type <- match.arg(type) args <- trimws(args) diff --git a/R/tool-docs-news.R b/R/tool-docs-news.R index 00e8b318..4d4ecc19 100644 --- a/R/tool-docs-news.R +++ b/R/tool-docs-news.R @@ -217,7 +217,7 @@ package_news_search <- function( r_docs <- r_docs_versions() if (!package_name %in% r_docs) { - check_installed(package_name) + rlang::check_installed(package_name) } else { if (package_name == sprintf("R-%s", R.version$major)) { package_name <- "R" diff --git a/R/tool-env-df.R b/R/tool-env-df.R index 055e40d4..e829c669 100644 --- a/R/tool-env-df.R +++ b/R/tool-env-df.R @@ -185,7 +185,7 @@ get_dataset_from_package <- function(name, package = NULL) { return(missing_arg()) } - check_installed(package, call = caller_env()) + rlang::check_installed(package, call = caller_env()) env <- new.env() tryCatch( diff --git a/R/tool-files-search.R b/R/tool-files-search.R index 3bb49519..99bca12a 100644 --- a/R/tool-files-search.R +++ b/R/tool-files-search.R @@ -90,7 +90,7 @@ btw_tool_files_search_factory <- function( check_character(exclusions, allow_na = FALSE, allow_null = TRUE) rlang::check_installed("DBI") - rlang::check_installed("RSQLite") + rlang::check_installed("RSQLite", version = "2.2.2") project_path <- fs::path_norm(fs::path_abs(fs::path_expand(path))) state <- new.env(parent = emptyenv()) @@ -248,7 +248,7 @@ Use the `btw_tool_files_read` tool, if available, to read the full content of a open_world_hint = FALSE, idempotent_hint = FALSE, btw_can_register = function() { - is_installed("RSQLite") && is_installed("DBI") + is_installed("RSQLite", version = "2.2.2") && is_installed("DBI") } ), arguments = list( @@ -406,13 +406,17 @@ btw_search_refresh_index <- function( DBI::dbExecute( con, paste0( - "INSERT INTO projects (path, label, last_opened_at) VALUES (?, ?, ?) ", + "INSERT INTO projects (path, label, last_opened_at, search_indexed_at) ", + "VALUES (?, ?, ?, ?) ", "ON CONFLICT(path) DO UPDATE SET ", - "label = excluded.label, last_opened_at = excluded.last_opened_at" + "label = excluded.label, ", + "last_opened_at = excluded.last_opened_at, ", + "search_indexed_at = excluded.search_indexed_at" ), params = list( project_path, fs::path_file(project_path), + format(Sys.time(), tz = "UTC", usetz = TRUE), format(Sys.time(), tz = "UTC", usetz = TRUE) ) ) @@ -629,7 +633,10 @@ btw_search_prune_stale_indexes <- function(con) { cutoff <- format(Sys.time() - days * 24 * 60 * 60, tz = "UTC", usetz = TRUE) stale <- DBI::dbGetQuery( con, - "SELECT path FROM projects WHERE last_opened_at IS NOT NULL AND last_opened_at < ?", + paste0( + "SELECT path FROM projects ", + "WHERE search_indexed_at IS NOT NULL AND search_indexed_at < ?" + ), params = list(cutoff) )$path if (length(stale) == 0) { @@ -649,6 +656,11 @@ btw_search_prune_stale_indexes <- function(con) { "DELETE FROM files WHERE project_path = ?", params = list(project_path) ) + DBI::dbExecute( + con, + "UPDATE projects SET search_indexed_at = NULL WHERE path = ?", + params = list(project_path) + ) } invisible(NULL) diff --git a/R/tool-github.R b/R/tool-github.R index f19b4bf7..515ffeab 100644 --- a/R/tool-github.R +++ b/R/tool-github.R @@ -17,7 +17,7 @@ btw_eval_gh_code <- function( method = "evaluate", show_last_value = TRUE ) { - check_installed("gh") + rlang::check_installed("gh") wd_repo_info <- get_github_repo(NULL, NULL) @@ -88,7 +88,7 @@ get_github_repo <- function(owner = NULL, repo = NULL) { return(list(owner = owner, repo = repo)) } - check_installed("gh") + rlang::check_installed("gh") remote_info <- new_environment() gh_tr <- tryCatch( diff --git a/R/tool-pkg-src.R b/R/tool-pkg-src.R index ffa65218..7a4d629b 100644 --- a/R/tool-pkg-src.R +++ b/R/tool-pkg-src.R @@ -14,7 +14,7 @@ btw_pkg_src_resolve_ns <- function(package) { check_string(package) if (identical(package, ".")) { - check_installed("pkgload") + rlang::check_installed("pkgload") pkgload::load_all(".", export_all = FALSE, quiet = TRUE) name <- pkgload::pkg_name(".") } else { @@ -174,7 +174,7 @@ btw_pkg_src_has_source_tree <- function(r_dir) { # a redundant `load_all()`/`loadNamespace()`. btw_pkg_src_path_info <- function(package) { if (identical(package, ".")) { - check_installed("pkgload") + rlang::check_installed("pkgload") list(path = pkgload::pkg_path("."), source_available = TRUE) } else { path <- find.package(package) @@ -681,7 +681,7 @@ btw_tool_pkg_src_search_impl <- function( cli::cli_abort("`terms` must contain at least one search term.") } - check_installed("RSQLite") + rlang::check_installed("RSQLite", version = "2.2.2") check_installed("DBI") path_info <- btw_pkg_src_path_info(package) diff --git a/tests/testthat/_snaps/tool-env-df.md b/tests/testthat/_snaps/tool-env-df.md index c95f91ff..4482cf0a 100644 --- a/tests/testthat/_snaps/tool-env-df.md +++ b/tests/testthat/_snaps/tool-env-df.md @@ -149,8 +149,7 @@ btw_tool_env_describe_data_frame("skibidi::ohio") Condition Error in `btw_tool_env_describe_data_frame()`: - ! Package skibidi is not installed. - i Did you mean "Kifidi", "simIDM", "bib2df", "BiBitR", or "Bioi"? + ! The package "skibidi" is required. # btw_this.tbl() works diff --git a/tests/testthat/test-btw-project-db.R b/tests/testthat/test-btw-project-db.R index b3203708..ae8ec678 100644 --- a/tests/testthat/test-btw-project-db.R +++ b/tests/testthat/test-btw-project-db.R @@ -22,8 +22,27 @@ test_that("btw_db_open() initializes the shared schema", { "SELECT value FROM state WHERE key = ?", params = list("schema_version") )$value, - "1" + "2" ) + expect_true("search_indexed_at" %in% DBI::dbListFields(con, "projects")) +}) + +test_that("btw_db_open() upgrades existing projects tables", { + path <- fs::path(withr::local_tempdir(), "btw.sqlite3") + con <- DBI::dbConnect(RSQLite::SQLite(), dbname = path) + DBI::dbExecute( + con, + "CREATE TABLE projects ( + path TEXT PRIMARY KEY, + label TEXT, + active_conversation_id TEXT, + last_opened_at TEXT + )" + ) + DBI::dbDisconnect(con) + + con <- btw:::btw_db_open(path, .envir = environment()) + expect_true("search_indexed_at" %in% DBI::dbListFields(con, "projects")) }) test_that("per-project search table names are deterministic and safe", { diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index caea274b..3d949852 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -213,7 +213,7 @@ test_that("btw docs news guides positional search terms to --search", { test_that("btw docs news errors for non-existent package", { result <- run_btw_subprocess("docs", "news", "nonexistent_pkg_xyz") expect_equal(result$status, 1) - expect_match(result$stderr, "not installed|not found", ignore.case = TRUE) + expect_match(result$stderr, "is required", ignore.case = TRUE) }) # pkg group -------------------------------------------------------------- diff --git a/tests/testthat/test-tool-docs-news.R b/tests/testthat/test-tool-docs-news.R index cad67fee..fb73f338 100644 --- a/tests/testthat/test-tool-docs-news.R +++ b/tests/testthat/test-tool-docs-news.R @@ -66,7 +66,7 @@ test_that("btw_tool_docs_package_news() selects a requested version", { test_that("btw_tool_docs_package_news() with non-existent package", { expect_error( btw_tool_docs_package_news("nonexistentpackage"), - "not installed" + "is required" ) }) diff --git a/tests/testthat/test-tool-files-search.R b/tests/testthat/test-tool-files-search.R index bf8d4474..3102d023 100644 --- a/tests/testthat/test-tool-files-search.R +++ b/tests/testthat/test-tool-files-search.R @@ -172,8 +172,14 @@ test_that("stale search cleanup is limited to the idle project index", { DBI::dbExecute( con, - "INSERT INTO projects (path, label, last_opened_at) VALUES (?, ?, ?), (?, ?, ?)", - params = list(idle_path, "idle", old, active_path, "active", now) + paste0( + "INSERT INTO projects (path, label, last_opened_at, search_indexed_at) ", + "VALUES (?, ?, ?, ?), (?, ?, ?, ?)" + ), + params = list( + idle_path, "idle", old, old, + active_path, "active", now, now + ) ) DBI::dbExecute( con, @@ -210,4 +216,12 @@ test_that("stale search cleanup is limited to the idle project index", { DBI::dbGetQuery(con, "SELECT COUNT(*) AS n FROM conversations")$n, 1 ) + expect_identical( + DBI::dbGetQuery( + con, + "SELECT search_indexed_at FROM projects WHERE path = ?", + params = list(idle_path) + )$search_indexed_at, + NA_character_ + ) }) From 317fd51a1a8d53201c06df88b2bee1a4805732f3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 10:09:50 -0400 Subject: [PATCH 08/11] fix: skip code search without RSQLite --- R/tool-files-search.R | 3 +++ tests/testthat/test-tool-files-search.R | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/R/tool-files-search.R b/R/tool-files-search.R index 99bca12a..27919149 100644 --- a/R/tool-files-search.R +++ b/R/tool-files-search.R @@ -215,6 +215,9 @@ btw_tool_files_search_factory <- function( name = "btw_tool_files_search", group = "files", alias_name = "btw_tool_files_code_search", + can_register = function() { + is_installed("RSQLite", version = "2.2.2") && is_installed("DBI") + }, tool = function() { project_code_search <- btw_tool_files_search_factory() ellmer::tool( diff --git a/tests/testthat/test-tool-files-search.R b/tests/testthat/test-tool-files-search.R index 3102d023..ae1517c5 100644 --- a/tests/testthat/test-tool-files-search.R +++ b/tests/testthat/test-tool-files-search.R @@ -14,6 +14,15 @@ local_file_search <- function(..., .env = caller_env()) { search } +test_that("file search is not registered without RSQLite", { + local_mocked_bindings( + is_installed = function(package, version = NULL) package != "RSQLite", + .package = "btw" + ) + + expect_false("btw_tool_files_search" %in% names(btw_tools("files"))) +}) + test_that("file search persists its index and refreshes changed files", { local_btw_db() search_dir <- fs::path_temp("search-persistence") From c221f385a684c1f4fde3017963061656a0bf0db5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 10:13:28 -0400 Subject: [PATCH 09/11] fix: skip unavailable optional tools on load --- R/zzz.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/zzz.R b/R/zzz.R index 5d09c061..efc78c10 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -3,7 +3,7 @@ S7::methods_register() pkg_env <- rlang::fn_env(btw_tools) - for (tool_def in as_ellmer_tools(.btw_tools, force = TRUE)) { + for (tool_def in as_ellmer_tools(.btw_tools)) { assign(tool_def@name, tool_def, envir = pkg_env) } From 977494862e1853aede81eacd1e0e6ba2e00a9ed0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 10:16:52 -0400 Subject: [PATCH 10/11] fix: defer SQLite search initialization --- R/tool-files-search.R | 27 +++++++++++++++++++++++-- R/zzz.R | 2 +- tests/testthat/test-tool-files-search.R | 12 +++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/R/tool-files-search.R b/R/tool-files-search.R index 27919149..a7cfac5c 100644 --- a/R/tool-files-search.R +++ b/R/tool-files-search.R @@ -211,6 +211,30 @@ btw_tool_files_search_factory <- function( } } +btw_tool_files_search_impl <- local({ + project_code_search <- NULL + + function( + term, + limit = 100, + case_sensitive = TRUE, + use_regex = FALSE, + show_lines = FALSE + ) { + if (is.null(project_code_search)) { + project_code_search <<- btw_tool_files_search_factory() + } + + project_code_search( + term, + limit = limit, + case_sensitive = case_sensitive, + use_regex = use_regex, + show_lines = show_lines + ) + } +}) + .btw_add_to_tools( name = "btw_tool_files_search", group = "files", @@ -219,7 +243,6 @@ btw_tool_files_search_factory <- function( is_installed("RSQLite", version = "2.2.2") && is_installed("DBI") }, tool = function() { - project_code_search <- btw_tool_files_search_factory() ellmer::tool( function( term, @@ -228,7 +251,7 @@ btw_tool_files_search_factory <- function( use_regex = FALSE, show_lines = FALSE ) { - project_code_search( + btw_tool_files_search_impl( term, limit = limit, case_sensitive = case_sensitive, diff --git a/R/zzz.R b/R/zzz.R index efc78c10..5d09c061 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -3,7 +3,7 @@ S7::methods_register() pkg_env <- rlang::fn_env(btw_tools) - for (tool_def in as_ellmer_tools(.btw_tools)) { + for (tool_def in as_ellmer_tools(.btw_tools, force = TRUE)) { assign(tool_def@name, tool_def, envir = pkg_env) } diff --git a/tests/testthat/test-tool-files-search.R b/tests/testthat/test-tool-files-search.R index ae1517c5..3b70cdd1 100644 --- a/tests/testthat/test-tool-files-search.R +++ b/tests/testthat/test-tool-files-search.R @@ -23,6 +23,18 @@ test_that("file search is not registered without RSQLite", { expect_false("btw_tool_files_search" %in% names(btw_tools("files"))) }) +test_that("file search definition materializes without RSQLite", { + local_mocked_bindings( + is_installed = function(package, version = NULL) package != "RSQLite", + .package = "btw" + ) + + expect_silent(as_ellmer_tools( + .btw_tools["btw_tool_files_search"], + force = TRUE + )) +}) + test_that("file search persists its index and refreshes changed files", { local_btw_db() search_dir <- fs::path_temp("search-persistence") From 6b12877101a316f2607346ed5e96bd69d8c87837 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 9 Sep 2026 10:29:18 -0400 Subject: [PATCH 11/11] test: avoid undeclared search test dependency --- R/tool-files-search.R | 10 ++++++---- R/zzz.R | 3 +++ tests/testthat/test-tool-files-search.R | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/R/tool-files-search.R b/R/tool-files-search.R index a7cfac5c..a5b2a47b 100644 --- a/R/tool-files-search.R +++ b/R/tool-files-search.R @@ -212,7 +212,8 @@ btw_tool_files_search_factory <- function( } btw_tool_files_search_impl <- local({ - project_code_search <- NULL + state <- new.env(parent = emptyenv()) + state$project_code_search <- NULL function( term, @@ -221,11 +222,12 @@ btw_tool_files_search_impl <- local({ use_regex = FALSE, show_lines = FALSE ) { - if (is.null(project_code_search)) { - project_code_search <<- btw_tool_files_search_factory() + if (is.null(state$project_code_search)) { + state$project_code_search <- btw_tool_files_search_factory() } - project_code_search( + rlang::exec( + state$project_code_search, term, limit = limit, case_sensitive = case_sensitive, diff --git a/R/zzz.R b/R/zzz.R index 5d09c061..f8f507b1 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -22,8 +22,11 @@ if (getRversion() < "4.3.0") { "description", "extra", "name", + "private", "properties", "value" ) ) } + +utils::globalVariables("private") diff --git a/tests/testthat/test-tool-files-search.R b/tests/testthat/test-tool-files-search.R index 3b70cdd1..9826dcfb 100644 --- a/tests/testthat/test-tool-files-search.R +++ b/tests/testthat/test-tool-files-search.R @@ -91,7 +91,9 @@ test_that("file search optimizes only after many indexed-file deletions", { expect_equal(optimized, 0L) paths <- fs::path(sprintf("code-%03d.R", seq_len(101L))) - purrr::walk(paths, writeLines, text = "many_search_terms <- TRUE") + for (path in paths) { + writeLines("many_search_terms <- TRUE", path) + } search("many_search_terms") fs::file_delete(paths) search("many_search_terms")