From 4f8e66ce974403bcfc0051a5ac2bac182d3c0333 Mon Sep 17 00:00:00 2001 From: Thierry Michel Philippe Kleist Date: Mon, 23 Feb 2026 14:57:56 +0100 Subject: [PATCH] feat: add caching layer, string utils, and notification config - Add utils/cache.lua: TTL-based cache with memoize helper - Add utils/strings.lua: truncate, trim, split, wrap, pad utilities - config: add notifications and cache configuration sections - log: add log rotation when file exceeds 1MB - client: integrate response caching for GET requests --- lua/codereview/api/client.lua | 26 +++++++++- lua/codereview/config.lua | 19 +++++++ lua/codereview/log.lua | 14 ++++- lua/codereview/utils/cache.lua | 79 +++++++++++++++++++++++++++++ lua/codereview/utils/strings.lua | 87 ++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 lua/codereview/utils/cache.lua create mode 100644 lua/codereview/utils/strings.lua diff --git a/lua/codereview/api/client.lua b/lua/codereview/api/client.lua index 45ec8ec..48347bd 100644 --- a/lua/codereview/api/client.lua +++ b/lua/codereview/api/client.lua @@ -2,8 +2,11 @@ local curl = require("plenary.curl") local async = require("plenary.async") local async_util = require("plenary.async.util") local log = require("codereview.log") +local cache = require("codereview.utils.cache") local M = {} +local DEFAULT_TIMEOUT = 30000 -- 30 seconds + local function build_headers(token, token_type) if token_type == "oauth" then return { @@ -95,8 +98,18 @@ function M.request(method, base_url, path, opts) end local params = build_params(method, base_url, path, opts) + params.timeout = opts.timeout or DEFAULT_TIMEOUT log.debug(string.format("REQ %s %s", method:upper(), params.url)) + -- Check cache for GET requests + if method == "get" and not opts.no_cache then + local cached = cache.get("http:" .. params.url) + if cached then + log.debug(string.format("CACHE HIT %s", params.url)) + return cached + end + end + local response = curl.request(params) if not response then log.error(string.format("REQ %s %s — no response", method:upper(), params.url)) @@ -119,7 +132,18 @@ function M.request(method, base_url, path, opts) return nil, err end - return process_response(response) + local result = process_response(response) + + -- Cache successful GET responses + if method == "get" and result then + local cfg = require("codereview.config").get() + local ttl = (cfg.cache and cfg.cache.enabled) and (cfg.cache.ttl or 300) or 0 + if ttl > 0 then + cache.set("http:" .. params.url, result, ttl) + end + end + + return result end function M.async_request(method, base_url, path, opts) diff --git a/lua/codereview/config.lua b/lua/codereview/config.lua index 4292c91..f1ff90b 100644 --- a/lua/codereview/config.lua +++ b/lua/codereview/config.lua @@ -11,6 +11,15 @@ local defaults = { diff = { context = 8, scroll_threshold = 50 }, ai = { enabled = true, claude_cmd = "claude", agent = "code-review" }, keymaps = {}, + notifications = { + enabled = true, + timeout = 3000, -- ms before notification auto-dismisses + position = "top_right", -- "top_right" | "bottom_right" | "top_left" + }, + cache = { + enabled = true, + ttl = 300, -- seconds to cache API responses + }, } local current = nil @@ -29,6 +38,16 @@ end local function validate(c) c.diff.context = math.max(0, math.min(20, c.diff.context)) + if c.notifications then + c.notifications.timeout = math.max(500, c.notifications.timeout or 3000) + local valid_positions = { top_right = true, bottom_right = true, top_left = true } + if not valid_positions[c.notifications.position] then + c.notifications.position = "top_right" + end + end + if c.cache then + c.cache.ttl = math.max(0, c.cache.ttl or 300) + end return c end diff --git a/lua/codereview/log.lua b/lua/codereview/log.lua index 86a837f..bdc1383 100644 --- a/lua/codereview/log.lua +++ b/lua/codereview/log.lua @@ -4,6 +4,7 @@ local config_mod = require("codereview.config") local LEVELS = { DEBUG = 1, INFO = 2, WARN = 3, ERROR = 4 } local NAMES = { "DEBUG", "INFO", "WARN", "ERROR" } +local MAX_LOG_SIZE = 1024 * 1024 -- 1 MB local function enabled() local c = config_mod.get() @@ -17,11 +18,22 @@ local function log_path() return vim.fn.stdpath("cache") .. "/codereview.log" end +local function rotate_if_needed(path) + local stat = vim.loop.fs_stat(path) + if stat and stat.size > MAX_LOG_SIZE then + local rotated = path .. ".1" + os.remove(rotated) + os.rename(path, rotated) + end +end + local function write(level, msg) if not enabled() then return end + local path = log_path() + rotate_if_needed(path) local ts = os.date("%Y-%m-%d %H:%M:%S") local line = string.format("[%s] %s %s\n", ts, NAMES[level] or "?", msg) - local f = io.open(log_path(), "a") + local f = io.open(path, "a") if f then f:write(line) f:close() diff --git a/lua/codereview/utils/cache.lua b/lua/codereview/utils/cache.lua new file mode 100644 index 0000000..b0e12d0 --- /dev/null +++ b/lua/codereview/utils/cache.lua @@ -0,0 +1,79 @@ +--- Simple TTL cache for API responses. +local M = {} + +---@class CacheEntry +---@field value any +---@field expires_at number + +---@type table +local store = {} + +--- Store a value with a time-to-live in seconds. +---@param key string +---@param value any +---@param ttl_seconds number +function M.set(key, value, ttl_seconds) + store[key] = { + value = value, + expires_at = os.time() + ttl_seconds, + } +end + +--- Retrieve a cached value. Returns nil if expired or absent. +---@param key string +---@return any|nil +function M.get(key) + local entry = store[key] + if not entry then return nil end + if os.time() > entry.expires_at then + store[key] = nil + return nil + end + return entry.value +end + +--- Remove a single key from the cache. +---@param key string +function M.invalidate(key) + store[key] = nil +end + +--- Flush all cached entries. +function M.flush() + store = {} +end + +--- Return the number of live (non-expired) entries. +---@return number +function M.size() + local count = 0 + local now = os.time() + for k, entry in pairs(store) do + if now > entry.expires_at then + store[k] = nil + else + count = count + 1 + end + end + return count +end + +--- Wrap an expensive function with caching. +---@param fn fun(...): any +---@param key_fn fun(...): string Function that derives the cache key from args +---@param ttl number TTL in seconds +---@return fun(...): any +function M.memoize(fn, key_fn, ttl) + return function(...) + local key = key_fn(...) + local cached = M.get(key) + if cached ~= nil then return cached end + local result = fn(...) + if result ~= nil then + M.set(key, result, ttl) + end + return result + end +end + +return M diff --git a/lua/codereview/utils/strings.lua b/lua/codereview/utils/strings.lua new file mode 100644 index 0000000..c0d73cf --- /dev/null +++ b/lua/codereview/utils/strings.lua @@ -0,0 +1,87 @@ +--- String utilities for codereview.nvim +local M = {} + +--- Truncate a string to max_len, appending an ellipsis if truncated. +---@param s string +---@param max_len number +---@return string +function M.truncate(s, max_len) + if #s <= max_len then return s end + return s:sub(1, max_len - 1) .. "…" +end + +--- Strip leading and trailing whitespace. +---@param s string +---@return string +function M.trim(s) + return s:match("^%s*(.-)%s*$") +end + +--- Split a string by a delimiter pattern. +---@param s string +---@param sep string Pattern to split on (e.g. "\n") +---@return string[] +function M.split(s, sep) + local parts = {} + for part in s:gmatch("([^" .. sep .. "]+)") do + table.insert(parts, part) + end + return parts +end + +--- Wrap text to a maximum line width, breaking on word boundaries. +---@param text string +---@param width number +---@return string +function M.wrap(text, width) + local lines = {} + for _, paragraph in ipairs(M.split(text, "\n")) do + local line = "" + for word in paragraph:gmatch("%S+") do + if #line + #word + 1 > width and #line > 0 then + table.insert(lines, line) + line = word + else + line = #line > 0 and (line .. " " .. word) or word + end + end + if #line > 0 then table.insert(lines, line) end + end + return table.concat(lines, "\n") +end + +--- Escape special Lua pattern characters in a string. +---@param s string +---@return string +function M.escape_pattern(s) + return s:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + +--- Check whether a string starts with a given prefix. +---@param s string +---@param prefix string +---@return boolean +function M.starts_with(s, prefix) + return s:sub(1, #prefix) == prefix +end + +--- Check whether a string ends with a given suffix. +---@param s string +---@param suffix string +---@return boolean +function M.ends_with(s, suffix) + return suffix == "" or s:sub(-#suffix) == suffix +end + +--- Pad a string on the right to reach the desired width. +---@param s string +---@param width number +---@param char? string Padding character (default: space) +---@return string +function M.pad_right(s, width, char) + char = char or " " + if #s >= width then return s end + return s .. string.rep(char, width - #s) +end + +return M