diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ebdaa3..1a9181b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,8 +82,12 @@ assets. Install it with `git config core.hooksPath .githooks`. - Don't hardcode field-specific semantics. Effort, priority coefficients, UDA interpretation must go through configurable mappers. See `DEFAULT_URGENCY_VALUE_MAPPERS` in `lua/taskwarrior/taskmd.lua` for the pattern. -- When shelling out, always include `rc.bulk=0 rc.confirmation=off` in the - Taskwarrior invocation. Interactive prompts break headless usage. +- Route plugin-owned Taskwarrior processes through + `require("taskwarrior.command")`. Use `read()` for queries, `mutate()` for + state changes, and `start()` for asynchronous work. Do not call + `vim.fn.system*` or `vim.fn.jobstart` with `task` directly: the command + boundary owns availability checks, non-interactive rc flags, argv safety, + and exit-result handling. The isolated tutor database is the sole exception. - Sanitize `\n` out of any string before `nvim_buf_set_lines` — vim treats those as a buffer-corruption error. - Never trust `vim.cmd("normal!")` in headless tests — it silently no-ops diff --git a/lua/taskwarrior/apply.lua b/lua/taskwarrior/apply.lua index 3251058..ff5b040 100644 --- a/lua/taskwarrior/apply.lua +++ b/lua/taskwarrior/apply.lua @@ -1,13 +1,14 @@ local M = {} +local command = require("taskwarrior.command") -- Backup the Taskwarrior data directory before applying changes. Best-effort: -- failures are reported but do not block the apply. local function backup_taskdata() local config = require("taskwarrior.config") if not config.options.auto_backup then return end - local ok, taskdata_raw = pcall(vim.fn.system, "task _get rc.data.location 2>/dev/null") - if not ok then return end - local taskdata = tostring(taskdata_raw or ""):gsub("%s+$", "") + local location = command.read({ "_get", "rc.data.location" }) + if not location.ok then return end + local taskdata = tostring(location.output or ""):gsub("%s+$", "") if taskdata == "" or vim.fn.isdirectory(taskdata) ~= 1 then return end local data = vim.fn.stdpath("data") local dest_root = data .. "/taskwarrior.nvim/backups" @@ -20,10 +21,8 @@ local function backup_taskdata() vim.fn.mkdir(dest_root, "p") local stamp = os.date("%Y-%m-%d-%H%M%S") local dest = dest_root .. "/" .. stamp - local copy_ok, copy_err = pcall(function() - vim.fn.system(string.format("cp -a %s %s", - vim.fn.shellescape(taskdata), vim.fn.shellescape(dest))) - end) + local copy_ok, copy_err = pcall(vim.fn.system, { "cp", "-a", taskdata, dest }) + copy_ok = copy_ok and vim.v.shell_error == 0 if not copy_ok then vim.notify("taskwarrior.nvim: auto-backup failed (" .. tostring(copy_err) .. ")", vim.log.levels.WARN) @@ -268,18 +267,28 @@ function M.undo(bufnr, refresh_fn) prompt = string.format("Undo %d action(s) from last save?", count), }, function(choice) if choice ~= "Undo" then return end - local failed = 0 + local succeeded = 0 + local failure_output for _ = 1, count do - vim.fn.system({ "task", "rc.bulk=0", "rc.confirmation=off", "undo" }) - if vim.v.shell_error ~= 0 then failed = failed + 1 end + local result = command.mutate({ "undo" }) + if not result.ok then + failure_output = result.output + break + end + succeeded = succeeded + 1 end - vim.b[bufnr].task_last_action_count = nil - if failed > 0 then - vim.notify(string.format("taskwarrior.nvim: undo completed (%d failed)", failed), vim.log.levels.WARN) + local remaining = count - succeeded + vim.b[bufnr].task_last_action_count = remaining > 0 and remaining or nil + if remaining > 0 then + local msg = string.format( + "taskwarrior.nvim: undid %d action(s); %d still pending", + succeeded, remaining) + if failure_output and failure_output ~= "" then msg = msg .. "\n" .. failure_output end + vim.notify(msg, vim.log.levels.ERROR) else vim.notify(string.format("taskwarrior.nvim: undid %d action(s)", count)) end - refresh_fn(bufnr) + if succeeded > 0 then refresh_fn(bufnr) end end) end @@ -325,7 +334,9 @@ function M.do_apply_and_refresh(bufnr, tmpfile, on_delete, refresh_fn, opts) end if summary.errors and #summary.errors > 0 then msg = msg .. string.format(" (%d errors!)", #summary.errors) - vim.notify(msg, vim.log.levels.WARN) + local first = summary.errors[1] and summary.errors[1].error + if first and first ~= "" then msg = msg .. "\n" .. first end + vim.notify(msg, vim.log.levels.ERROR) elseif (summary.action_count or 0) > 0 then vim.notify(msg) end diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 6d3f90f..70ab765 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -1,15 +1,10 @@ local M = {} +local command = require("taskwarrior.command") -- --------------------------------------------------------------------------- -- Shared utilities -- --------------------------------------------------------------------------- -local function run(cmd) - local out = vim.fn.system(cmd) - local ok = vim.v.shell_error == 0 - return out, ok -end - local function uuid_from_line(line) return line:match("") end @@ -913,11 +908,8 @@ function M.setup_buf_keymaps(bufnr) end vim.ui.input({ prompt = "Annotation: " }, function(text) if not text or text == "" then return end - local _, ok = run( - string.format("task rc.bulk=0 rc.confirmation=off %s annotate %s", - short_uuid, vim.fn.shellescape(text)) - ) - if ok then + local result = command.mutate({ short_uuid, "annotate", text }) + if result.ok then vim.notify("taskwarrior.nvim: annotation added") M.refresh_buf(bufnr) else @@ -939,12 +931,16 @@ function M.setup_buf_keymaps(bufnr) completion = "custom,v:lua.require'taskwarrior'._complete_modify", }, function(input) if not input or input == "" then return end - local escaped = input:gsub("'", "'\\''") - local _, ok = run( - string.format("task rc.bulk=0 rc.confirmation=off %s modify '%s'", - short_uuid, escaped) - ) - if ok then + local parts, err = command.parse_args(input) + if not parts then + vim.notify("taskwarrior.nvim: invalid modify arguments\n" .. err, + vim.log.levels.ERROR) + return + end + local args = { short_uuid, "modify" } + vim.list_extend(args, parts) + local result = command.mutate(args) + if result.ok then vim.notify("taskwarrior.nvim: modified") M.refresh_buf(bufnr) else @@ -1021,10 +1017,9 @@ function M.setup_buf_keymaps(bufnr) vim.notify("taskwarrior.nvim: no UUID on this line", vim.log.levels.WARN) return end - local out, ok = run( - string.format("task rc.bulk=0 rc.confirmation=off %s info", short_uuid) - ) - if not ok or out == "" then + local result = command.read({ short_uuid, "info" }) + local out = result.output + if not result.ok or out == "" then vim.notify("taskwarrior.nvim: info failed", vim.log.levels.ERROR) return end diff --git a/lua/taskwarrior/bulk.lua b/lua/taskwarrior/bulk.lua index 33e5cf9..150890b 100644 --- a/lua/taskwarrior/bulk.lua +++ b/lua/taskwarrior/bulk.lua @@ -4,16 +4,12 @@ -- entirely (rc.bulk=0 + one invocation per task). local M = {} +local command = require("taskwarrior.command") local function uuid_from_line(line) return line:match("") end -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end - -- range: { line1, line2 } (1-based, inclusive) -- spec: the modify spec, e.g. "+triage project:inbox" function M.modify(range, spec) @@ -40,15 +36,25 @@ function M.modify(range, spec) end local failed = 0 + local parts, parse_err = command.parse_args(spec) + if not parts then + require("taskwarrior.notify")("error", + "taskwarrior.nvim: invalid modify arguments\n" .. parse_err, + vim.log.levels.ERROR) + return + end for _, u in ipairs(uuids) do - local _, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s modify %s", u, spec)) - if not ok then failed = failed + 1 end + local args = { u, "modify" } + vim.list_extend(args, parts) + if not command.mutate(args).ok then failed = failed + 1 end end local msg = string.format("taskwarrior.nvim: modified %d task%s", #uuids - failed, (#uuids - failed) ~= 1 and "s" or "") if failed > 0 then msg = msg .. " (" .. failed .. " failed)" end - require("taskwarrior.notify")("modify", msg) + require("taskwarrior.notify")( + failed > 0 and "error" or "modify", + msg, + failed > 0 and vim.log.levels.ERROR or nil) if vim.b[bufnr].task_filter ~= nil then pcall(function() require("taskwarrior.buffer").refresh_buf(bufnr) end) diff --git a/lua/taskwarrior/capture.lua b/lua/taskwarrior/capture.lua index e8dbf85..89fad01 100644 --- a/lua/taskwarrior/capture.lua +++ b/lua/taskwarrior/capture.lua @@ -1,10 +1,5 @@ local M = {} - -local function run(cmd) - local out = vim.fn.system(cmd) - local ok = vim.v.shell_error == 0 - return out, ok -end +local command = require("taskwarrior.command") -- Omnifunc for the capture window — delegates to task.completion.complete_filter -- so users get project:, +tag, priority:, field: completions with . @@ -121,7 +116,7 @@ function M.open(refresh_fn) else vim.notify("taskwarrior.nvim: add failed", vim.log.levels.ERROR) end - refresh_fn() + if add_ok then refresh_fn() end return end end @@ -129,9 +124,8 @@ function M.open(refresh_fn) -- Fallback only when parse_capture itself failed (taskmd module missing, -- or input was unparseable). Use literal add so the user doesn't lose -- their typed content. - local escaped = line:gsub("'", "'\\''") - local _, ok = run("task rc.bulk=0 rc.confirmation=off add -- '" .. escaped .. "'") - if ok then + local result = command.mutate({ "add", "--", line }) + if result.ok then vim.notify("taskwarrior.nvim: added task (unparsed)") refresh_fn() else diff --git a/lua/taskwarrior/cmp.lua b/lua/taskwarrior/cmp.lua index d2b30ed..e72e330 100644 --- a/lua/taskwarrior/cmp.lua +++ b/lua/taskwarrior/cmp.lua @@ -8,6 +8,7 @@ -- - bare word → completes field names (project:, priority:, due:, etc.) local source = {} +local command = require("taskwarrior.command") local KNOWN_FIELDS = { "project:", "priority:", "due:", "scheduled:", "recur:", @@ -23,19 +24,19 @@ local function refresh_cache() local now = vim.loop.now() / 1000 if _cache.mtime + 10 > now and _cache.projects then return end _cache.mtime = now - local function lines(cmd) - local out = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 then return {} end + local function lines(subcommand) + local result = command.read({ subcommand }) + if not result.ok then return {} end local r = {} - for _, l in ipairs(out) do + for l in result.output:gmatch("[^\r\n]+") do l = l:gsub("%s+$", "") if l ~= "" then table.insert(r, l) end end return r end - _cache.projects = lines("task _projects 2>/dev/null") - _cache.tags = lines("task _tags 2>/dev/null") - _cache.udas = lines("task _udas 2>/dev/null") + _cache.projects = lines("_projects") + _cache.tags = lines("_tags") + _cache.udas = lines("_udas") end function source.new() diff --git a/lua/taskwarrior/command.lua b/lua/taskwarrior/command.lua new file mode 100644 index 0000000..f3380dd --- /dev/null +++ b/lua/taskwarrior/command.lua @@ -0,0 +1,171 @@ +-- Central Taskwarrior process boundary. +-- +-- Every plugin-owned `task` invocation should pass through this module. It +-- owns availability checks, non-interactive rc overrides, argv construction, +-- exit-status capture, and the result shape consumed by callers. + +local M = {} + +M.MUTATION_RC = { "rc.bulk=0", "rc.confirmation=off" } +M.READ_RC = { + "rc.bulk=0", + "rc.confirmation=off", + "rc.verbose=nothing", + "rc.color=off", +} + +local function copy_list(values) + local result = {} + for _, value in ipairs(values or {}) do result[#result + 1] = tostring(value) end + return result +end + +function M.parse_args(text) + if text == nil or text == "" then return {} end + if type(text) == "table" then return copy_list(text) end + if type(text) ~= "string" then + return nil, "Taskwarrior arguments must be a string or list" + end + local args, current = {}, {} + local quote, escaped, started = nil, false, false + local function finish() + if started then + args[#args + 1] = table.concat(current) + current, started = {}, false + end + end + for i = 1, #text do + local char = text:sub(i, i) + if escaped then + current[#current + 1] = char + escaped, started = false, true + elseif quote == "'" then + if char == "'" then quote = nil else current[#current + 1] = char end + started = true + elseif quote == '"' then + if char == '"' then + quote = nil + elseif char == "\\" then + escaped = true + else + current[#current + 1] = char + end + started = true + elseif char == "'" or char == '"' then + quote, started = char, true + elseif char == "\\" then + escaped, started = true, true + elseif char:match("%s") then + finish() + else + current[#current + 1] = char + started = true + end + end + if escaped then return nil, "unfinished escape in Taskwarrior arguments" end + if quote then return nil, "unclosed quote in Taskwarrior arguments" end + finish() + return args +end + +local function accepted(code, ok_codes) + if ok_codes == nil then return code == 0 end + for _, allowed in ipairs(ok_codes) do + if code == allowed then return true end + end + return false +end + +local function build_argv(args, opts) + local parsed, err = M.parse_args(args) + if not parsed then return nil, err end + local argv = { "task" } + local rc = opts.rc + if rc == nil then + rc = opts.kind == "read" and M.READ_RC or M.MUTATION_RC + end + for _, value in ipairs(rc or {}) do argv[#argv + 1] = value end + vim.list_extend(argv, parsed) + return argv +end + +local function failed_result(output, code, argv, reason) + return { + ok = false, + output = output or "", + code = code, + argv = argv, + reason = reason, + } +end + +--- Run Taskwarrior synchronously and return one stable result object. +--- opts.kind: "read" or "mutation" (mutation is the safe default). +--- opts.rc: explicit rc override list; {} disables defaults. +--- opts.ok_codes: accepted process exit codes (default {0}). +function M.run(args, opts) + opts = opts or {} + if not require("taskwarrior.runtime").ensure_available() then + return failed_result("task executable is unavailable", 127, nil, "unavailable") + end + local argv, build_err = build_argv(args, opts) + if not argv then return failed_result(build_err, -1, nil, "invalid-arguments") end + local called, output = pcall(vim.fn.system, argv) + if not called then return failed_result(tostring(output), -1, argv, "spawn-error") end + local code = vim.v.shell_error + return { + ok = accepted(code, opts.ok_codes), + output = output or "", + code = code, + argv = argv, + } +end + +function M.read(args, opts) + opts = vim.tbl_extend("force", opts or {}, { kind = "read" }) + return M.run(args, opts) +end + +function M.mutate(args, opts) + opts = vim.tbl_extend("force", opts or {}, { kind = "mutation" }) + return M.run(args, opts) +end + +--- Asynchronous Taskwarrior invocation with the same result contract. +function M.start(args, opts, callback) + opts = opts or {} + callback = callback or function() end + if not require("taskwarrior.runtime").ensure_available() then + callback(failed_result("task executable is unavailable", 127, nil, "unavailable")) + return nil + end + local argv, build_err = build_argv(args, opts) + if not argv then + callback(failed_result(build_err, -1, nil, "invalid-arguments")) + return nil + end + local stdout, stderr = {}, {} + local started, job = pcall(vim.fn.jobstart, argv, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) if data then vim.list_extend(stdout, data) end end, + on_stderr = function(_, data) if data then vim.list_extend(stderr, data) end end, + on_exit = function(_, code) + callback({ + ok = accepted(code, opts.ok_codes), + output = table.concat(stdout, "\n"), + stderr = table.concat(stderr, "\n"), + code = code, + argv = argv, + }) + end, + }) + if not started or job <= 0 then + local detail = started and "failed to start task process" or tostring(job) + callback(failed_result(detail, -1, argv, "spawn-error")) + return nil + end + return job +end + +return M diff --git a/lua/taskwarrior/dashboard.lua b/lua/taskwarrior/dashboard.lua index 9201838..bc33ccd 100644 --- a/lua/taskwarrior/dashboard.lua +++ b/lua/taskwarrior/dashboard.lua @@ -26,23 +26,13 @@ local M = {} -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end - --- Return up to `n` pending tasks as a list of pretty-printed lines --- (sorted by urgency descending). If no tasks exist, returns a single --- "No pending tasks" line so dashboards render something meaningful. function M.top_urgent(n) n = n or 5 - local out, ok = run( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export") - if not ok or not out or out == "" then return { "No pending tasks" } end - local js = out:find("%[") - if js and js > 1 then out = out:sub(js) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" or #tasks == 0 then + local tasks = require("taskwarrior.taskmd").shell_export("status:pending") + if not tasks or #tasks == 0 then return { "No pending tasks" } end table.sort(tasks, function(a, b) return (a.urgency or 0) > (b.urgency or 0) end) diff --git a/lua/taskwarrior/delegate.lua b/lua/taskwarrior/delegate.lua index 92e0040..f5c786f 100644 --- a/lua/taskwarrior/delegate.lua +++ b/lua/taskwarrior/delegate.lua @@ -1,11 +1,5 @@ local M = {} -local function run(cmd) - local out = vim.fn.system(cmd) - local ok = vim.v.shell_error == 0 - return out, ok -end - local function uuid_from_line(line) return line:match("") end diff --git a/lua/taskwarrior/feedback.lua b/lua/taskwarrior/feedback.lua index 15f529b..9de1605 100644 --- a/lua/taskwarrior/feedback.lua +++ b/lua/taskwarrior/feedback.lua @@ -240,13 +240,14 @@ local function build_payload(report_sections) local uname = vim.loop.os_uname() local os_str = (uname.sysname or "unknown") .. "/" .. (uname.machine or "unknown") - local tw_ver_raw = vim.fn.system("task --version 2>/dev/null") + local tw_ver_raw = require("taskwarrior.command").read( + { "--version" }, { rc = {} }).output local tw_ver = vim.trim((tw_ver_raw or ""):match("^[^\n]+") or "") if tw_ver == "" then tw_ver = "unknown" end local backend = opts.backend or "lua" - local task_count_raw = vim.fn.system("task rc.bulk=0 rc.confirmation=off count 2>/dev/null") + local task_count_raw = require("taskwarrior.command").read({ "count" }).output local task_count = tonumber((task_count_raw or ""):match("%d+")) or 0 -- DP-style bucket so we never share a unique-identifying integer. -- See lua/taskwarrior/feedback/privacy.lua + the design doc. diff --git a/lua/taskwarrior/granulation.lua b/lua/taskwarrior/granulation.lua index 31260fb..8876695 100644 --- a/lua/taskwarrior/granulation.lua +++ b/lua/taskwarrior/granulation.lua @@ -16,24 +16,15 @@ -- M.stop_all_now() — force-stop every started task (used on VimLeavePre) local M = {} +local command = require("taskwarrior.command") local timer = nil local augroup = nil -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end - -- Return a list of { uuid, description } for every currently-started task. local function list_started() - local out, ok = run( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on +ACTIVE export") - if not ok or not out or out == "" then return {} end - local js = out:find("%[") - if js and js > 1 then out = out:sub(js) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return {} end + local tasks = require("taskwarrior.taskmd").shell_export("+ACTIVE") + if not tasks then return nil end local started = {} for _, t in ipairs(tasks) do if t.start and t.uuid then @@ -45,22 +36,35 @@ end local function stop_all(reason) local started = list_started() + local notify = require("taskwarrior.notify") + if not started then + notify("error", "taskwarrior.nvim: failed to check active tasks", + vim.log.levels.ERROR) + return + end if #started == 0 then return end local config = require("taskwarrior.config") - local notify = require("taskwarrior.notify") + local stopped, failed = 0, 0 for _, t in ipairs(started) do - run(string.format("task rc.bulk=0 rc.confirmation=off %s stop", - t.uuid:sub(1, 8))) + local result = command.mutate({ t.uuid:sub(1, 8), "stop" }) + if result.ok then stopped = stopped + 1 else failed = failed + 1 end end - if config.options.granulation.notify_on_stop ~= false then + if stopped > 0 and config.options.granulation.notify_on_stop ~= false then notify("stop", string.format( "taskwarrior.nvim: auto-stopped %d task%s (%s)", - #started, #started > 1 and "s" or "", reason or "idle")) + stopped, stopped > 1 and "s" or "", reason or "idle")) + end + if failed > 0 then + notify("error", string.format( + "taskwarrior.nvim: failed to auto-stop %d task%s", + failed, failed > 1 and "s" or ""), vim.log.levels.ERROR) end -- Refresh any visible task buffers so the [>] markers drop. - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(b) and vim.b[b].task_filter ~= nil then - pcall(function() require("taskwarrior.buffer").refresh_buf(b) end) + if stopped > 0 then + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.b[b].task_filter ~= nil then + pcall(function() require("taskwarrior.buffer").refresh_buf(b) end) + end end end end diff --git a/lua/taskwarrior/graph.lua b/lua/taskwarrior/graph.lua index 9eb5832..88bd3f5 100644 --- a/lua/taskwarrior/graph.lua +++ b/lua/taskwarrior/graph.lua @@ -7,11 +7,6 @@ local M = {} -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end - -- Produce a label safe for Mermaid's `["..."]` node syntax. -- Mermaid's string parser is stricter than it looks: `"` inside the quoted -- form terminates the string unless escaped, `#` opens a comment, and line diff --git a/lua/taskwarrior/health.lua b/lua/taskwarrior/health.lua index 0b1ccdc..c03b2ad 100644 --- a/lua/taskwarrior/health.lua +++ b/lua/taskwarrior/health.lua @@ -1,4 +1,5 @@ local M = {} +local command = require("taskwarrior.command") M.check = function() vim.health.start("taskwarrior.nvim") @@ -12,7 +13,7 @@ M.check = function() -- Taskwarrior if require("taskwarrior.runtime").is_task_available() then - local tw_version = vim.fn.system("task --version"):gsub("%s+$", "") + local tw_version = command.read({ "--version" }, { rc = {} }).output:gsub("%s+$", "") local major = tonumber(tw_version:match("^(%d+)")) if major and major >= 2 then vim.health.ok("Taskwarrior " .. tw_version) @@ -26,7 +27,7 @@ M.check = function() -- Task data directory. Skip when `task` is missing — `task _get` would -- silently shell-fail and we'd report a bogus default path. if require("taskwarrior.runtime").is_task_available() then - local taskdata = vim.fn.system("task _get rc.data.location"):gsub("%s+$", "") + local taskdata = command.read({ "_get", "rc.data.location" }).output:gsub("%s+$", "") if taskdata ~= "" and vim.fn.isdirectory(taskdata) == 1 then vim.health.ok("Taskwarrior data at " .. taskdata) else diff --git a/lua/taskwarrior/inbox.lua b/lua/taskwarrior/inbox.lua index 70c3d9a..c60d8ec 100644 --- a/lua/taskwarrior/inbox.lua +++ b/lua/taskwarrior/inbox.lua @@ -7,11 +7,7 @@ -- organized yet". local M = {} - -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end +local command = require("taskwarrior.command") -- Accept hours as a single optional integer (default 24). function M.run(hours) @@ -84,46 +80,53 @@ function M.run(hours) local action if choice == "drop" then action = function(cb) - run(string.format("task rc.bulk=0 rc.confirmation=off %s delete", short)) - cb() + local result = command.mutate({ short, "delete" }) + cb(result.ok, result.output) end elseif choice == "defer (wait 1d)" then action = function(cb) - run(string.format("task rc.bulk=0 rc.confirmation=off %s modify wait:1d", short)) - cb() + local result = command.mutate({ short, "modify", "wait:1d" }) + cb(result.ok, result.output) end elseif choice == "set project" then action = function(cb) vim.ui.input({ prompt = "Project: " }, function(v) if v and v ~= "" then - run(string.format("task rc.bulk=0 rc.confirmation=off %s modify project:%s", - short, v)) + local result = command.mutate({ short, "modify", "project:" .. v }) + return cb(result.ok, result.output) end - cb() + cb(nil, "") end) end elseif choice == "schedule" then action = function(cb) vim.ui.input({ prompt = "Due (e.g. tomorrow, eow): " }, function(v) if v and v ~= "" then - run(string.format("task rc.bulk=0 rc.confirmation=off %s modify due:%s", - short, v)) + local result = command.mutate({ short, "modify", "due:" .. v }) + return cb(result.ok, result.output) end - cb() + cb(nil, "") end) end elseif choice == "tag" then action = function(cb) vim.ui.input({ prompt = "Tag (no +): " }, function(v) if v and v ~= "" then - run(string.format("task rc.bulk=0 rc.confirmation=off %s modify +%s", - short, v)) + local result = command.mutate({ short, "modify", "+" .. v }) + return cb(result.ok, result.output) end - cb() + cb(nil, "") end) end end - action(function() + action(function(ok, out) + if ok == nil then return vim.schedule(walk) end + if not ok then + require("taskwarrior.notify")("error", + "taskwarrior.nvim: inbox action failed\n" .. (out or ""), + vim.log.levels.ERROR) + return vim.schedule(walk) + end idx = idx + 1 vim.schedule(walk) end) diff --git a/lua/taskwarrior/init.lua b/lua/taskwarrior/init.lua index cb42472..32d9404 100644 --- a/lua/taskwarrior/init.lua +++ b/lua/taskwarrior/init.lua @@ -1,10 +1,5 @@ local M = {} - -local function run(cmd) - local out = vim.fn.system(cmd) - local ok = vim.v.shell_error == 0 - return out, ok -end +local command = require("taskwarrior.command") local function uuid_from_line(line) return line:match("") @@ -117,9 +112,8 @@ function M.start_stop(which) vim.notify("taskwarrior.nvim: no UUID on this line", vim.log.levels.WARN) return end - local cmd = string.format("task rc.bulk=0 rc.confirmation=off %s %s", short_uuid, which) - local _, ok = run(cmd) - if ok then + local result = command.mutate({ short_uuid, which }) + if result.ok then vim.notify(string.format("taskwarrior.nvim: %s %s", which, short_uuid)) if vim.b[bufnr].task_filter ~= nil then refresh_buf(bufnr) end else diff --git a/lua/taskwarrior/modify.lua b/lua/taskwarrior/modify.lua index 3256737..91c94f2 100644 --- a/lua/taskwarrior/modify.lua +++ b/lua/taskwarrior/modify.lua @@ -8,11 +8,7 @@ local M = {} local notify = require("taskwarrior.notify") - -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end +local command = require("taskwarrior.command") local function uuid_from_line(line) return line:match("") @@ -29,12 +25,6 @@ local function current_uuid() return u end --- Try the Python CLI-sanitizing shell-escape before falling back to the --- stdlib single-quote replacement. Works on all platforms nvim supports. -local function shq(s) - return vim.fn.shellescape(s or "") -end - -- --------------------------------------------------------------------------- -- Refresh helper: refresh every currently-open task buffer. -- modify.lua doesn't know whether the current buffer is a task buffer (it may @@ -62,15 +52,12 @@ function M.append_or_prepend(mode, text) if not uuid then return end local function finish(input) if not input or input == "" then return end - local cmd = string.format( - "task rc.bulk=0 rc.confirmation=off %s %s %s", - uuid, mode, shq(input)) - local out, ok = run(cmd) - if ok then + local result = command.mutate({ uuid, mode, input }) + if result.ok then notify("modify", string.format("taskwarrior.nvim: %s → %s", mode, uuid)) refresh_all_task_buffers() else - notify("error", string.format("taskwarrior.nvim: %s failed\n%s", mode, out), + notify("error", string.format("taskwarrior.nvim: %s failed\n%s", mode, result.output), vim.log.levels.ERROR) end end @@ -94,13 +81,12 @@ function M.prepend(text) M.append_or_prepend("prepend", text) end function M.duplicate() local uuid = current_uuid() if not uuid then return end - local out, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s duplicate", uuid)) - if ok then + local result = command.mutate({ uuid, "duplicate" }) + if result.ok then notify("modify", "taskwarrior.nvim: duplicated " .. uuid) refresh_all_task_buffers() else - notify("error", "taskwarrior.nvim: duplicate failed\n" .. out, vim.log.levels.ERROR) + notify("error", "taskwarrior.nvim: duplicate failed\n" .. result.output, vim.log.levels.ERROR) end end @@ -123,13 +109,18 @@ function M.purge(filter) notify("modify", "taskwarrior.nvim: purge cancelled") return end - local out, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s purge", filter)) - if ok then + local args, err = command.parse_args(filter) + if not args then + notify("error", "taskwarrior.nvim: invalid purge filter\n" .. err, vim.log.levels.ERROR) + return + end + args[#args + 1] = "purge" + local result = command.mutate(args) + if result.ok then notify("modify", "taskwarrior.nvim: purged " .. filter) refresh_all_task_buffers() else - notify("error", "taskwarrior.nvim: purge failed\n" .. out, vim.log.levels.ERROR) + notify("error", "taskwarrior.nvim: purge failed\n" .. result.output, vim.log.levels.ERROR) end end) end @@ -158,13 +149,12 @@ function M.denotate() local anns = task.annotations local function finish(text) if not text or text == "" then return end - local out, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s denotate %s", uuid, shq(text))) - if ok then + local result = command.mutate({ uuid, "denotate", text }) + if result.ok then notify("modify", "taskwarrior.nvim: annotation removed") refresh_all_task_buffers() else - notify("error", "taskwarrior.nvim: denotate failed\n" .. out, vim.log.levels.ERROR) + notify("error", "taskwarrior.nvim: denotate failed\n" .. result.output, vim.log.levels.ERROR) end end if #anns == 1 then @@ -184,13 +174,19 @@ end -- --------------------------------------------------------------------------- local function modify_field(uuid, spec) - local out, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s modify %s", uuid, spec)) - if ok then + local parts, err = command.parse_args(spec) + if not parts then + notify("error", "taskwarrior.nvim: invalid modify arguments\n" .. err, vim.log.levels.ERROR) + return + end + local args = { uuid, "modify" } + vim.list_extend(args, parts) + local result = command.mutate(args) + if result.ok then notify("modify", "taskwarrior.nvim: " .. spec) refresh_all_task_buffers() else - notify("error", "taskwarrior.nvim: modify failed\n" .. out, vim.log.levels.ERROR) + notify("error", "taskwarrior.nvim: modify failed\n" .. result.output, vim.log.levels.ERROR) end end diff --git a/lua/taskwarrior/nested.lua b/lua/taskwarrior/nested.lua index de35ac5..1551890 100644 --- a/lua/taskwarrior/nested.lua +++ b/lua/taskwarrior/nested.lua @@ -14,11 +14,7 @@ -- at the direct children. local M = {} - -local function run(cmd) - local out = vim.fn.system(cmd) - return out, vim.v.shell_error == 0 -end +local command = require("taskwarrior.command") local function uuid_from_line(line) return line:match("") @@ -68,9 +64,8 @@ function M.link_children() return end local depends = table.concat(children, ",") - local _, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s modify depends:%s", parent, depends)) - if ok then + local result = command.mutate({ parent, "modify", "depends:" .. depends }) + if result.ok then notify("modify", string.format( "taskwarrior.nvim: linked %d child task%s → %s", #children, #children > 1 and "s" or "", parent:sub(1, 8))) @@ -98,15 +93,17 @@ function M.unlink_children() for _, uuid in ipairs(children) do table.insert(minus, "-" .. uuid) end - local _, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off %s modify depends:%s", - parent, table.concat(minus, ","))) - if ok then + local result = command.mutate({ + parent, "modify", "depends:" .. table.concat(minus, ","), + }) + if result.ok then notify("modify", "taskwarrior.nvim: unlinked children") local bufnr = vim.api.nvim_get_current_buf() if vim.b[bufnr].task_filter ~= nil then pcall(function() require("taskwarrior.buffer").refresh_buf(bufnr) end) end + else + notify("error", "taskwarrior.nvim: unlink failed", vim.log.levels.ERROR) end end diff --git a/lua/taskwarrior/review.lua b/lua/taskwarrior/review.lua index 7106b62..81bf9c9 100644 --- a/lua/taskwarrior/review.lua +++ b/lua/taskwarrior/review.lua @@ -1,25 +1,15 @@ local M = {} - -local function run(cmd) - local out = vim.fn.system(cmd) - local ok = vim.v.shell_error == 0 - return out, ok -end +local command = require("taskwarrior.command") -- run_review: walk through pending tasks one by one. -- open_fn: callback(filter_str) to open a task buffer (M.open from init) function M.run(open_fn) - local out, ok = run( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export") - if not ok or not out or out == "" then + local tasks = require("taskwarrior.taskmd").shell_export("status:pending") + if not tasks then vim.notify("taskwarrior.nvim: failed to export tasks", vim.log.levels.ERROR) return end - local js = out - local s = js:find("%[") - if s and s > 1 then js = js:sub(s) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, js) - if not parsed_ok or type(tasks) ~= "table" or #tasks == 0 then + if #tasks == 0 then vim.notify("taskwarrior.nvim: no pending tasks", vim.log.levels.INFO) return end @@ -62,16 +52,34 @@ function M.run(open_fn) if key == "k" then idx = idx + 1; step() elseif key == "d" then - run(string.format("task rc.bulk=0 rc.confirmation=off %s modify wait:tomorrow", short)) + local result = command.mutate({ short, "modify", "wait:tomorrow" }) + if not result.ok then + vim.notify("taskwarrior.nvim: defer failed\n" .. result.output, vim.log.levels.ERROR) + return vim.schedule(step) + end idx = idx + 1; step() elseif key == "x" then - run(string.format("task rc.bulk=0 rc.confirmation=off %s done", short)) + local result = command.mutate({ short, "done" }) + if not result.ok then + vim.notify("taskwarrior.nvim: done failed\n" .. result.output, vim.log.levels.ERROR) + return vim.schedule(step) + end idx = idx + 1; step() elseif key == "m" then vim.ui.input({ prompt = "Modify " .. short .. ": " }, function(input) - if input and input ~= "" then - local esc = input:gsub("'", "'\\''") - run(string.format("task rc.bulk=0 rc.confirmation=off %s modify '%s'", short, esc)) + if not input or input == "" then return vim.schedule(step) end + local parts, err = command.parse_args(input) + if not parts then + vim.notify("taskwarrior.nvim: invalid modify arguments\n" .. err, + vim.log.levels.ERROR) + return vim.schedule(step) + end + local args = { short, "modify" } + vim.list_extend(args, parts) + local result = command.mutate(args) + if not result.ok then + vim.notify("taskwarrior.nvim: modify failed\n" .. result.output, vim.log.levels.ERROR) + return vim.schedule(step) end idx = idx + 1; step() end) diff --git a/lua/taskwarrior/sync.lua b/lua/taskwarrior/sync.lua index 5de536c..fc7e013 100644 --- a/lua/taskwarrior/sync.lua +++ b/lua/taskwarrior/sync.lua @@ -6,29 +6,16 @@ -- setups also use `task sync`, so the wrapper works for both. local M = {} - -local function run_async(cmd, on_done) - local stdout = {} - local stderr = {} - vim.fn.jobstart(cmd, { - stdout_buffered = true, - stderr_buffered = true, - on_stdout = function(_, data) if data then vim.list_extend(stdout, data) end end, - on_stderr = function(_, data) if data then vim.list_extend(stderr, data) end end, - on_exit = function(_, code) - on_done(code, stdout, stderr) - end, - }) -end +local command = require("taskwarrior.command") function M.run() local notify = require("taskwarrior.notify") notify("apply", "taskwarrior.nvim: syncing…") - run_async({ "task", "rc.bulk=0", "rc.confirmation=off", "sync" }, - function(code, stdout, stderr) - local out = table.concat(stdout, "\n") - local err = table.concat(stderr, "\n") - if code == 0 then + command.start({ "sync" }, { kind = "mutation" }, + function(result) + local out = result.output or "" + local err = result.stderr or "" + if result.ok then local summary = (out .. "\n" .. err):gsub("^%s+", ""):gsub("%s+$", "") if summary == "" then summary = "sync complete" end notify("apply", "taskwarrior.nvim: " .. summary:sub(1, 200)) @@ -49,7 +36,7 @@ function M.run() hint = "\n(hint: authentication failed — check sync credentials)" end notify("error", - "taskwarrior.nvim: sync failed (exit " .. code .. ")\n" + "taskwarrior.nvim: sync failed (exit " .. result.code .. ")\n" .. (err ~= "" and err or out) .. hint, vim.log.levels.ERROR) vim.ui.select({ "retry", "cancel" }, { prompt = "taskwarrior.nvim:" }, diff --git a/lua/taskwarrior/taskmd.lua b/lua/taskwarrior/taskmd.lua index 2c68f79..117a2e4 100644 --- a/lua/taskwarrior/taskmd.lua +++ b/lua/taskwarrior/taskmd.lua @@ -27,22 +27,7 @@ M.DATE_FIELDS = { due = true, scheduled = true, wait = true, ["until"] = true } local KNOWN_FIELDS = M.KNOWN_FIELDS local LIST_FIELDS = M.LIST_FIELDS local DATE_FIELDS = M.DATE_FIELDS - -local BASE_RC = { "rc.bulk=0", "rc.confirmation=off" } - --- rc overrides for READ-ONLY invocations whose stdout gets parsed (export --- JSON, _udas/_projects/_tags line lists). vim.fn.system merges stderr into --- stdout, so chatter must be suppressed at the source (issue #5): --- rc.verbose=nothing — silences footnotes, the `task news` nag, and --- "Configuration override" notices (which our own --- rc.* args would otherwise trigger for users with --- verbose=override). --- rc.color=off — guards against forced-color configs injecting ANSI --- escapes into the parse stream. --- Hook output can still leak through; decode_json_array tolerates that. --- Mutation paths keep BASE_RC — tw_add parses "Created task " from --- verbose output, so silencing there would break it. -local READ_RC = { "rc.bulk=0", "rc.confirmation=off", "rc.verbose=nothing", "rc.color=off" } +local command = require("taskwarrior.command") -- --------------------------------------------------------------------------- -- Small helpers @@ -179,30 +164,9 @@ end M.DEFAULT_URGENCY_VALUE_MAPPERS.effort = M.effort_to_minutes -- --------------------------------------------------------------------------- --- Taskwarrior adapter (subprocess via vim.fn.system) +-- Taskwarrior adapter -- --------------------------------------------------------------------------- --- Layer B — runtime defense for the missing-binary case. Layer A --- (startup-time check in plugin/taskwarrior.lua) catches users who never --- had Taskwarrior installed; this catches users who had it at startup --- but lost it mid-session (uninstall, PATH change, container teardown). --- Without this guard, vim.fn.system({"task",...}) raises E475 from --- inside vim.schedule and surfaces to the user as a Lua trace. --- --- WARN throttling and the executable check live in --- lua/taskwarrior/runtime.lua so every code path that spawns `task` --- shares one cached check + one notification policy. -local function run(argv) - if not vim or not vim.fn then - error("taskmd.lua requires the vim global (must run inside neovim)") - end - if not require("taskwarrior.runtime").ensure_available() then - return "", 127 - end - local out = vim.fn.system(argv) - return out, vim.v.shell_error -end - -- Taskwarrior's built-in virtual tags. These are computed per-task (e.g. -- +ACTIVE for started tasks, +OVERDUE for tasks past due). They are NOT -- stored in the task's tag list — so rewriting `+ACTIVE` as @@ -357,33 +321,31 @@ function M.decode_json_array(text) return nil end --- Shell-string export for callers that assemble their filter as a plain --- string (graph, inbox, views, telescope, …). The shell form lets us --- redirect stderr away from the parse stream entirely — combined with --- READ_RC suppression and decode_json_array this is the hardened --- replacement for the ten hand-rolled `find("%[")` slices that issue #5 --- exposed. Returns a list (possibly empty), or nil when the command failed --- or its output was unparseable. +-- Export helper for callers that assemble filters as strings (graph, inbox, +-- views, telescope, …). command.parse_args converts Taskwarrior's filter +-- syntax to argv without invoking a shell. Returns a list (possibly empty), +-- or nil when the command failed or its output was unparseable. function M.shell_export(filter_str) - if not require("taskwarrior.runtime").ensure_available() then return nil end - local cmd = string.format( - "task %s rc.json.array=on %s export 2>/dev/null", - table.concat(READ_RC, " "), filter_str or "") - local out = vim.fn.system(cmd) - if vim.v.shell_error ~= 0 and vim.v.shell_error ~= 1 then return nil end - return M.decode_json_array(out) + local args, err = command.parse_args(filter_str) + if not args then return nil, err end + table.insert(args, 1, "rc.json.array=on") + args[#args + 1] = "export" + local result = command.read(args, { ok_codes = { 0, 1 } }) + if not result.ok then return nil, result.output end + return M.decode_json_array(result.output) end function M.tw_export(filter_args) - local argv = { "task" } - for _, a in ipairs(READ_RC) do argv[#argv + 1] = a end - argv[#argv + 1] = "rc.json.array=on" - for _, a in ipairs(normalize_tag_filters(normalize_duration_minutes(filter_args))) do argv[#argv + 1] = a end - argv[#argv + 1] = "export" - local text, rc = run(argv) - if rc ~= 0 and rc ~= 1 then - error("task export failed: " .. tostring(text)) + local cmd_args = { "rc.json.array=on" } + for _, a in ipairs(normalize_tag_filters(normalize_duration_minutes(filter_args))) do + cmd_args[#cmd_args + 1] = a end + cmd_args[#cmd_args + 1] = "export" + local result = command.read(cmd_args, { ok_codes = { 0, 1 } }) + if not result.ok then + error("task export failed: " .. tostring(result.output)) + end + local text = result.output if not text or vim.trim(text) == "" then return {} end local parsed = M.decode_json_array(text) if not parsed then @@ -432,19 +394,16 @@ function M.tw_add(desc, fields) -- rc.verbose=new-uuid forces "Created task ." to stdout even when the -- user has `verbose=nothing` in their .taskrc. Without it, tw_add returns "" -- and callers that depend on a UUID (capture.submit) wrongly assume failure. - local argv = { "task" } - for _, a in ipairs(BASE_RC) do argv[#argv + 1] = a end - argv[#argv + 1] = "rc.verbose=new-uuid" - argv[#argv + 1] = "add" - argv[#argv + 1] = "description:" .. desc - for _, a in ipairs(fields_to_args(fields or {})) do argv[#argv + 1] = a end - local out, code = run(argv) - if code ~= 0 then return "", false end + local cmd_args = { "rc.verbose=new-uuid", "add", "description:" .. desc } + for _, a in ipairs(fields_to_args(fields or {})) do cmd_args[#cmd_args + 1] = a end + local result = command.mutate(cmd_args) + local out, code = result.output, result.code + if not result.ok then return "", false, out, code end local uuid = (out or ""):match("[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+") -- Keep the UUID as the first return value for existing callers. The second -- value distinguishes a failed command from a successful mutation whose -- output could not be parsed; capture must not retry the latter (issue #7). - return uuid or "", true + return uuid or "", true, out, code end function M.tw_modify(uuid, fields) @@ -453,22 +412,17 @@ function M.tw_modify(uuid, fields) fields.description = nil local parts = fields_to_args(fields) if desc ~= nil then parts[#parts + 1] = "description:" .. desc end - if #parts == 0 then return end - local argv = { "task" } - for _, a in ipairs(BASE_RC) do argv[#argv + 1] = a end - argv[#argv + 1] = uuid - argv[#argv + 1] = "modify" - for _, a in ipairs(parts) do argv[#argv + 1] = a end - run(argv) + if #parts == 0 then return true, "", 0 end + local cmd_args = { uuid, "modify" } + vim.list_extend(cmd_args, parts) + local result = command.mutate(cmd_args) + return result.ok, result.output, result.code end local function simple_tw(verb) return function(uuid) - local argv = { "task" } - for _, a in ipairs(BASE_RC) do argv[#argv + 1] = a end - argv[#argv + 1] = uuid - argv[#argv + 1] = verb - run(argv) + local result = command.mutate({ uuid, verb }) + return result.ok, result.output, result.code end end @@ -478,10 +432,8 @@ M.tw_start = simple_tw("start") M.tw_stop = simple_tw("stop") function M.tw_udas() - local argv = { "task" } - for _, a in ipairs(READ_RC) do argv[#argv + 1] = a end - argv[#argv + 1] = "_udas" - local out, _ = run(argv) + local result = command.read({ "_udas" }) + local out = result.ok and result.output or "" local known = list_to_set(KNOWN_FIELDS) known["priority"] = true local result = {} @@ -496,10 +448,8 @@ end function M.tw_completions() local function helper(cmd) - local argv = { "task" } - for _, a in ipairs(READ_RC) do argv[#argv + 1] = a end - argv[#argv + 1] = cmd - return run(argv) + local result = command.read({ cmd }) + return result.ok and result.output or "" end local p_out = helper("_projects") local t_out = helper("_tags") @@ -1337,32 +1287,65 @@ function M.apply(args) local summary = { added = 0, modified = 0, completed = 0, deleted = 0, - errors = {}, action_count = #diff, conflicts = conflicts, + errors = {}, action_count = 0, conflicts = conflicts, } + local function require_mutation(ok, out, label, code) + if ok then return end + local detail = trim(out or "") + if detail ~= "" then detail = ": " .. detail end + error(string.format("%s failed (exit %s)%s", label, tostring(code or "?"), detail)) + end + local order = { "add", "modify", "start", "stop", "done", "delete" } for _, atype in ipairs(order) do for _, action in ipairs(diff) do if action.type == atype then local ok, err = pcall(function() if atype == "add" then - local new_uuid = M.tw_add(action.description, action.fields) + local new_uuid, add_ok, out, code = M.tw_add(action.description, action.fields) + require_mutation(add_ok, out, "task add", code) summary.added = summary.added + 1 - if new_uuid ~= "" and action._post_done then - M.tw_done(new_uuid); summary.completed = summary.completed + 1 - elseif new_uuid ~= "" and action._post_start then - M.tw_start(new_uuid) + summary.action_count = summary.action_count + 1 + if action._post_done or action._post_start then + if new_uuid == "" then + error("task add succeeded but returned no UUID; cannot apply requested task state") + end + if action._post_done then + local state_ok, state_out, state_code = M.tw_done(new_uuid) + require_mutation(state_ok, state_out, "task done", state_code) + summary.completed = summary.completed + 1 + else + local state_ok, state_out, state_code = M.tw_start(new_uuid) + require_mutation(state_ok, state_out, "task start", state_code) + end + summary.action_count = summary.action_count + 1 end elseif atype == "modify" then - M.tw_modify(action.uuid, action.fields); summary.modified = summary.modified + 1 + local changed, out, code = M.tw_modify(action.uuid, action.fields) + require_mutation(changed, out, "task modify", code) + summary.modified = summary.modified + 1 + summary.action_count = summary.action_count + 1 elseif atype == "start" then - M.tw_start(action.uuid); summary.modified = summary.modified + 1 + local changed, out, code = M.tw_start(action.uuid) + require_mutation(changed, out, "task start", code) + summary.modified = summary.modified + 1 + summary.action_count = summary.action_count + 1 elseif atype == "stop" then - M.tw_stop(action.uuid); summary.modified = summary.modified + 1 + local changed, out, code = M.tw_stop(action.uuid) + require_mutation(changed, out, "task stop", code) + summary.modified = summary.modified + 1 + summary.action_count = summary.action_count + 1 elseif atype == "done" then - M.tw_done(action.uuid); summary.completed = summary.completed + 1 + local changed, out, code = M.tw_done(action.uuid) + require_mutation(changed, out, "task done", code) + summary.completed = summary.completed + 1 + summary.action_count = summary.action_count + 1 elseif atype == "delete" then - M.tw_delete(action.uuid); summary.deleted = summary.deleted + 1 + local changed, out, code = M.tw_delete(action.uuid) + require_mutation(changed, out, "task delete", code) + summary.deleted = summary.deleted + 1 + summary.action_count = summary.action_count + 1 end end) if not ok then diff --git a/lua/taskwarrior/views.lua b/lua/taskwarrior/views.lua index 6347c83..4484ed8 100644 --- a/lua/taskwarrior/views.lua +++ b/lua/taskwarrior/views.lua @@ -4,12 +4,6 @@ local M = {} -- Track open view buffers for refresh M._open_views = {} -- { bufnr = { type = "burndown"|"tree"|..., render_fn = function } } -local function run(cmd) - local out = vim.fn.system(cmd) - local ok = vim.v.shell_error == 0 - return out, ok -end - local function export_tasks(filter) filter = filter or "status:pending or status:completed" return require("taskwarrior.taskmd").shell_export(filter) or {} diff --git a/lua/telescope/_extensions/task.lua b/lua/telescope/_extensions/task.lua index bcd9f03..17b62a4 100644 --- a/lua/telescope/_extensions/task.lua +++ b/lua/telescope/_extensions/task.lua @@ -13,6 +13,7 @@ local conf = require("telescope.config").values local actions = require("telescope.actions") local action_state = require("telescope.actions.state") local previewers = require("telescope.previewers") +local command = require("taskwarrior.command") local function tw_export(filter) return require("taskwarrior.taskmd").shell_export(filter or "status:pending") or {} @@ -42,8 +43,10 @@ local function task_preview() title = "Task info", define_preview = function(self, entry) if not entry or not entry.short then return end - local out = vim.fn.systemlist(string.format("task %s info", entry.short)) - vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, out) + local result = command.read({ entry.short, "info" }) + local lines = vim.split(result.output or "", "\n", { plain = true }) + if not result.ok then lines = { "Taskwarrior info failed", result.output or "" } end + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) end, }) end @@ -75,10 +78,13 @@ local function picker(opts) map({ "i", "n" }, "", function() local selection = action_state.get_selected_entry() if not selection or not selection.short then return end - vim.fn.system(string.format( - "task rc.bulk=0 rc.confirmation=off %s done", selection.short)) + local result = command.mutate({ selection.short, "done" }) actions.close(prompt_bufnr) - vim.notify("taskwarrior.nvim: marked " .. selection.short .. " done") + if result.ok then + vim.notify("taskwarrior.nvim: marked " .. selection.short .. " done") + else + vim.notify("taskwarrior.nvim: done failed\n" .. result.output, vim.log.levels.ERROR) + end end) -- : start/stop map({ "i", "n" }, "", function() @@ -86,10 +92,14 @@ local function picker(opts) if not selection or not selection.short then return end local is_started = selection.value and selection.value.start local cmd = is_started and "stop" or "start" - vim.fn.system(string.format( - "task rc.bulk=0 rc.confirmation=off %s %s", selection.short, cmd)) + local result = command.mutate({ selection.short, cmd }) actions.close(prompt_bufnr) - vim.notify(string.format("taskwarrior.nvim: %s %s", cmd, selection.short)) + if result.ok then + vim.notify(string.format("taskwarrior.nvim: %s %s", cmd, selection.short)) + else + vim.notify("taskwarrior.nvim: " .. cmd .. " failed\n" .. result.output, + vim.log.levels.ERROR) + end end) -- : delete (with confirmation) map({ "i", "n" }, "", function() @@ -100,9 +110,13 @@ local function picker(opts) prompt = "Delete task " .. selection.short .. "?", }, function(choice) if choice ~= "yes" then return end - vim.fn.system(string.format( - "task rc.bulk=0 rc.confirmation=off %s delete", selection.short)) - vim.notify("taskwarrior.nvim: deleted " .. selection.short) + local result = command.mutate({ selection.short, "delete" }) + if result.ok then + vim.notify("taskwarrior.nvim: deleted " .. selection.short) + else + vim.notify("taskwarrior.nvim: delete failed\n" .. result.output, + vim.log.levels.ERROR) + end end) end) -- : yank UUID to unnamed register (and + if present) @@ -127,12 +141,17 @@ local function picker(opts) actions.close(prompt_bufnr) vim.ui.input({ prompt = "task " .. short .. " " }, function(verb) if not verb or verb == "" then return end - local out = vim.fn.system(string.format( - "task rc.bulk=0 rc.confirmation=off %s %s", short, verb)) - if vim.v.shell_error == 0 then + local args, err = command.parse_args(verb) + if not args then + vim.notify("taskwarrior.nvim: invalid arguments\n" .. err, vim.log.levels.ERROR) + return + end + table.insert(args, 1, short) + local result = command.mutate(args) + if result.ok then vim.notify("taskwarrior.nvim: " .. short .. " " .. verb) else - vim.notify("taskwarrior.nvim: failed\n" .. out, vim.log.levels.ERROR) + vim.notify("taskwarrior.nvim: failed\n" .. result.output, vim.log.levels.ERROR) end end) end) diff --git a/tests/e2e/spec/command_live_spec.lua b/tests/e2e/spec/command_live_spec.lua new file mode 100644 index 0000000..18797ca --- /dev/null +++ b/tests/e2e/spec/command_live_spec.lua @@ -0,0 +1,67 @@ +-- Live contract tests for taskwarrior.command. The e2e runner sets HOME, +-- TASKRC, and TASKDATA to a throwaway directory before Neovim starts, so +-- these tests never read or write the user's normal Taskwarrior database. + +local command = require("taskwarrior.command") +local taskmd = require("taskwarrior.taskmd") + +local TMP = assert(os.getenv("TASKWARRIOR_E2E_TMP"), + "command live spec requires tests/e2e/run.sh isolation") + +describe("live centralized command boundary", function() + it("runs against the isolated Taskwarrior database", function() + local location = command.read({ "_get", "rc.data.location" }) + assert.is_true(location.ok) + assert.are.equal(os.getenv("TASKDATA"), vim.trim(location.output)) + assert.is_truthy(vim.trim(location.output):find(TMP, 1, true)) + end) + + it("preserves shell-looking descriptions as literal argv data", function() + local marker = TMP .. "/command-boundary-must-not-exist" + local description = "LIVE_ARGV ; touch " .. marker .. " $(uname) 'quoted'" + + local uuid, ok, output, code = taskmd.tw_add(description, { project = "commandtest" }) + + assert.is_true(ok, (output or "") .. " (exit " .. tostring(code) .. ")") + assert.is_not.equal("", uuid) + assert.are.equal(0, vim.fn.filereadable(marker), + "shell-looking task text must never execute") + local tasks = taskmd.shell_export("uuid:" .. uuid) + assert.are.equal(1, #tasks) + assert.are.equal(description, tasks[1].description) + end) + + it("performs a real mutation and observes it through a real export", function() + local uuid, added = taskmd.tw_add("LIVE_MUTATION before", {}) + assert.is_true(added) + + local result = command.mutate({ uuid, "modify", "description:LIVE_MUTATION after" }) + assert.is_true(result.ok, result.output) + + local tasks = taskmd.shell_export("uuid:" .. uuid) + assert.are.equal(1, #tasks) + assert.are.equal("LIVE_MUTATION after", tasks[1].description) + end) + + it("returns a failed result for a rejected real mutation", function() + local before = taskmd.shell_export("status:pending") + local result = command.mutate({ "00000000-0000-0000-0000-000000000000", "done" }) + local after = taskmd.shell_export("status:pending") + + assert.is_false(result.ok) + assert.is_true(result.code ~= 0) + assert.are.equal(#before, #after, "rejected mutation must not alter task count") + end) + + it("returns the same contract from a real asynchronous read", function() + local result + local job = command.start({ "status:pending", "count" }, { kind = "read" }, + function(value) result = value end) + + assert.is_truthy(job) + assert.is_true(vim.wait(3000, function() return result ~= nil end, 10), + "timed out waiting for Taskwarrior count") + assert.is_true(result.ok, (result.stderr or "") .. (result.output or "")) + assert.is_truthy(tonumber(vim.trim(result.output))) + end) +end) diff --git a/tests/lua/spec/command_boundary_lint_spec.lua b/tests/lua/spec/command_boundary_lint_spec.lua new file mode 100644 index 0000000..ef50817 --- /dev/null +++ b/tests/lua/spec/command_boundary_lint_spec.lua @@ -0,0 +1,41 @@ +-- Guard the centralized Taskwarrior process boundary. Plugin-owned commands +-- belong in taskwarrior.command so availability, rc flags, argv safety, and +-- exit handling cannot drift independently again. + +describe("Taskwarrior command boundary", function() + it("has no direct task subprocess calls outside approved boundaries", function() + local source = debug.getinfo(1, "S").source:sub(2) + local repo_root = vim.fn.fnamemodify(source, ":h:h:h:h") + local allowed = { + ["lua/taskwarrior/command.lua"] = true, + -- Tutor validators intentionally use the isolated argv prefix created + -- by tutor/init.lua; routing them through the normal command boundary + -- would point them at the user's real Taskwarrior database. + ["lua/taskwarrior/tutor/lessons.lua"] = true, + -- Non-Taskwarrior subprocesses: backup `cp`, Git metadata, browser and + -- GitHub helpers. These are reviewed separately from the task boundary. + ["lua/taskwarrior/apply.lua"] = true, + ["lua/taskwarrior/feedback.lua"] = true, + } + local offenders = {} + local files = vim.fn.glob(repo_root .. "/lua/**/*.lua", true, true) + for _, file in ipairs(files) do + local relative = file:gsub(repo_root .. "/", "") + if not allowed[relative] then + local fh = io.open(file, "r") + if fh then + local content = fh:read("*all") + fh:close() + local direct_process = content:match("vim%.fn%.system%s*%(") + or content:match("vim%.fn%.systemlist%s*%(") + or content:match("vim%.fn%.jobstart%s*%(") + if direct_process then + offenders[#offenders + 1] = relative + end + end + end + end + assert.are.same({}, offenders, + "route Taskwarrior subprocesses through require('taskwarrior.command')") + end) +end) diff --git a/tests/lua/spec/command_spec.lua b/tests/lua/spec/command_spec.lua new file mode 100644 index 0000000..0d8a3a7 --- /dev/null +++ b/tests/lua/spec/command_spec.lua @@ -0,0 +1,150 @@ +local command = require("taskwarrior.command") +local runtime = require("taskwarrior.runtime") + +describe("taskwarrior.command", function() + local original_system + local original_jobstart + local original_available + + before_each(function() + original_system = vim.fn.system + original_jobstart = vim.fn.jobstart + original_available = runtime.ensure_available + runtime.ensure_available = function() return true end + end) + + after_each(function() + vim.fn.system = original_system + vim.fn.jobstart = original_jobstart + runtime.ensure_available = original_available + end) + + local function stub_exit(code, output, capture) + vim.fn.system = function(argv) + capture.argv = vim.deepcopy(argv) + original_system(code == 0 and "true" or "false") + return output or "" + end + end + + it("builds mutation argv and returns explicit success metadata", function() + local capture = {} + stub_exit(0, "ok", capture) + + local result = command.mutate({ "abc123", "done" }) + + assert.is_true(result.ok) + assert.are.equal(0, result.code) + assert.are.equal("ok", result.output) + assert.are.same({ + "task", "rc.bulk=0", "rc.confirmation=off", "abc123", "done", + }, capture.argv) + end) + + it("marks non-zero mutation exits as failures", function() + local capture = {} + stub_exit(1, "denied", capture) + + local result = command.mutate({ "abc123", "done" }) + + assert.is_false(result.ok) + assert.are.equal(1, result.code) + assert.are.equal("denied", result.output) + end) + + it("adds output-suppression rc overrides to reads", function() + local capture = {} + stub_exit(0, "[]", capture) + + command.read({ "status:pending", "export" }) + + assert.are.same({ + "task", "rc.bulk=0", "rc.confirmation=off", + "rc.verbose=nothing", "rc.color=off", "status:pending", "export", + }, capture.argv) + end) + + it("keeps every user value in a distinct argv element", function() + local capture = {} + stub_exit(0, "", capture) + + command.mutate({ "abc123", "annotate", "quoted ' text; $(ignored)" }) + + assert.are.equal("quoted ' text; $(ignored)", capture.argv[#capture.argv]) + end) + + it("parses quoted argument strings without invoking a shell", function() + assert.are.same( + { "project:Home Office", "+next", "description:quoted value" }, + command.parse_args([[project:"Home Office" +next 'description:quoted value']])) + end) + + it("rejects malformed quoted argument strings", function() + local args, err = command.parse_args([[project:"unfinished]]) + assert.is_nil(args) + assert.is_truthy(err:find("unclosed quote", 1, true)) + end) + + it("does not spawn when Taskwarrior is unavailable", function() + runtime.ensure_available = function() return false end + local called = false + vim.fn.system = function() called = true end + + local result = command.mutate({ "add", "description:x" }) + + assert.is_false(called) + assert.is_false(result.ok) + assert.are.equal(127, result.code) + assert.are.equal("unavailable", result.reason) + end) + + it("supports explicitly accepted read exit codes", function() + local capture = {} + stub_exit(1, "[]", capture) + + local result = command.read({ "export" }, { ok_codes = { 0, 1 } }) + + assert.is_true(result.ok) + assert.are.equal(1, result.code) + end) + + it("uses the same result contract for asynchronous commands", function() + local captured_argv + vim.fn.jobstart = function(argv, opts) + captured_argv = vim.deepcopy(argv) + opts.on_stdout(1, { "sync output" }) + opts.on_stderr(1, { "sync warning" }) + opts.on_exit(1, 0) + return 42 + end + local result + + local job = command.start({ "sync" }, { kind = "mutation" }, function(value) + result = value + end) + + assert.are.equal(42, job) + assert.is_true(result.ok) + assert.are.equal(0, result.code) + assert.are.equal("sync output", result.output) + assert.are.equal("sync warning", result.stderr) + assert.are.same({ + "task", "rc.bulk=0", "rc.confirmation=off", "sync", + }, captured_argv) + end) + + it("converts asynchronous spawn exceptions into failure results", function() + vim.fn.jobstart = function() error("synthetic spawn failure") end + local result + + local job = command.start({ "sync" }, { kind = "mutation" }, function(value) + result = value + end) + + assert.is_nil(job) + assert.is_false(result.ok) + assert.are.equal(-1, result.code) + assert.are.equal("spawn-error", result.reason) + assert.is_truthy(result.output:find("synthetic spawn failure", 1, true)) + end) +end) diff --git a/tests/lua/spec/mutation_result_spec.lua b/tests/lua/spec/mutation_result_spec.lua new file mode 100644 index 0000000..d34fef8 --- /dev/null +++ b/tests/lua/spec/mutation_result_spec.lua @@ -0,0 +1,106 @@ +-- Mutation result accounting: Taskwarrior exit status, not the absence of a +-- Lua exception, determines whether an apply action succeeded. + +local taskmd = require("taskwarrior.taskmd") + +local UUID = "12345678-1234-1234-1234-123456789abc" +local BASE = { + uuid = UUID, + status = "pending", + description = "before", + entry = "20260101T000000Z", + modified = "20260101T000000Z", +} + +local function apply_line(line) + return taskmd.apply({ content = line .. "\n", force = true }) +end + +describe("taskmd.apply mutation result accounting", function() + local originals + + before_each(function() + originals = { + tw_export = taskmd.tw_export, + tw_add = taskmd.tw_add, + tw_modify = taskmd.tw_modify, + tw_done = taskmd.tw_done, + tw_delete = taskmd.tw_delete, + tw_start = taskmd.tw_start, + tw_stop = taskmd.tw_stop, + } + end) + + after_each(function() + for name, fn in pairs(originals) do taskmd[name] = fn end + end) + + it("does not count a rejected modify as successful", function() + taskmd.tw_export = function() return { vim.deepcopy(BASE) } end + taskmd.tw_modify = function() + return false, "Taskwarrior rejected the field", 12 + end + + local summary = apply_line( + "- [ ] after ") + + assert.are.equal(0, summary.modified) + assert.are.equal(0, summary.action_count) + assert.are.equal(1, #summary.errors) + assert.is_truthy(summary.errors[1].error:find("exit 12", 1, true)) + assert.is_truthy(summary.errors[1].error:find("rejected the field", 1, true)) + end) + + it("counts a successful modify and its undoable command", function() + taskmd.tw_export = function() return { vim.deepcopy(BASE) } end + taskmd.tw_modify = function() return true, "", 0 end + + local summary = apply_line( + "- [ ] after ") + + assert.are.equal(1, summary.modified) + assert.are.equal(1, summary.action_count) + assert.are.same({}, summary.errors) + end) + + it("does not count a rejected add as successful", function() + taskmd.tw_export = function() return {} end + taskmd.tw_add = function() return "", false, "invalid due date", 1 end + + local summary = apply_line("- [ ] new task due:not-a-date") + + assert.are.equal(0, summary.added) + assert.are.equal(0, summary.action_count) + assert.are.equal(1, #summary.errors) + assert.is_truthy(summary.errors[1].error:find("invalid due date", 1, true)) + end) + + it("records a successful ambiguous add without retrying it", function() + taskmd.tw_export = function() return {} end + local calls = 0 + taskmd.tw_add = function() + calls = calls + 1 + return "", true, "Created task 1.", 0 + end + + local summary = apply_line("- [ ] new task") + + assert.are.equal(1, calls) + assert.are.equal(1, summary.added) + assert.are.equal(1, summary.action_count) + assert.are.same({}, summary.errors) + end) + + it("counts add and post-state as separate undoable commands", function() + taskmd.tw_export = function() return {} end + taskmd.tw_add = function() return UUID, true, "", 0 end + taskmd.tw_done = function() return true, "", 0 end + + local summary = apply_line("- [x] completed at creation") + + assert.are.equal(1, summary.added) + assert.are.equal(1, summary.completed) + assert.are.equal(2, summary.action_count) + assert.are.same({}, summary.errors) + end) +end) diff --git a/tests/lua/spec/workflow_mutation_failure_spec.lua b/tests/lua/spec/workflow_mutation_failure_spec.lua new file mode 100644 index 0000000..1c7b013 --- /dev/null +++ b/tests/lua/spec/workflow_mutation_failure_spec.lua @@ -0,0 +1,172 @@ +-- Interactive workflows must not advance or report success when Taskwarrior +-- rejects the command they issued. + +local taskmd = require("taskwarrior.taskmd") + +local UUID = "12345678-1234-1234-1234-123456789abc" + +local function force_system_failure(message) + local original = vim.fn.system + vim.fn.system = function(_) + original("false") -- update v:shell_error; it is read-only from Lua + return message or "Taskwarrior rejected the command" + end + return original +end + +describe("interactive mutation failures", function() + local original_export + local original_system + local original_select + local original_input + local original_notify + local original_notify_module + + before_each(function() + original_export = taskmd.shell_export + original_system = vim.fn.system + original_select = vim.ui.select + original_input = vim.ui.input + original_notify = vim.notify + original_notify_module = package.loaded["taskwarrior.notify"] + end) + + after_each(function() + taskmd.shell_export = original_export + vim.fn.system = original_system + vim.ui.select = original_select + vim.ui.input = original_input + vim.notify = original_notify + package.loaded["taskwarrior.notify"] = original_notify_module + package.loaded["taskwarrior.inbox"] = nil + package.loaded["taskwarrior.review"] = nil + package.loaded["taskwarrior.granulation"] = nil + end) + + it("keeps the same inbox item open after a failed action", function() + taskmd.shell_export = function() + return { + { + uuid = UUID, + description = "triage me", + entry = os.date("%Y%m%dT%H%M%SZ"), + }, + } + end + original_system = force_system_failure("delete denied") + + local prompts = {} + vim.ui.select = function(_, opts, callback) + prompts[#prompts + 1] = opts.prompt + callback(#prompts == 1 and "drop" or "quit") + end + local notices = {} + package.loaded["taskwarrior.notify"] = setmetatable({}, { + __call = function(_, kind, message, level) + notices[#notices + 1] = { kind = kind, message = message, level = level } + end, + }) + + require("taskwarrior.inbox").run(24) + vim.wait(100, function() return #prompts >= 2 end) + + assert.are.equal(2, #prompts) + assert.is_truthy(prompts[1]:find("[1/1]", 1, true)) + assert.is_truthy(prompts[2]:find("[1/1]", 1, true)) + assert.are.equal("error", notices[1].kind) + assert.is_truthy(notices[1].message:find("delete denied", 1, true)) + end) + + it("keeps the same review item open after a failed action", function() + taskmd.shell_export = function() + return { { uuid = UUID, description = "review me", urgency = 10 } } + end + original_system = force_system_failure("done denied") + + local prompts = {} + vim.ui.select = function(_, opts, callback) + prompts[#prompts + 1] = opts.prompt + callback(#prompts == 1 and "x Done" or "q Quit review") + end + local notices = {} + vim.notify = function(message, level) + notices[#notices + 1] = { message = message, level = level } + end + + require("taskwarrior.review").run(function() end) + vim.wait(100, function() return #prompts >= 2 end) + + assert.are.same({ "Review 1/1:", "Review 1/1:" }, prompts) + local saw_error, saw_complete = false, false + for _, notice in ipairs(notices) do + saw_error = saw_error or notice.message:find("done denied", 1, true) ~= nil + saw_complete = saw_complete or notice.message:find("review complete", 1, true) ~= nil + end + assert.is_true(saw_error) + assert.is_false(saw_complete) + end) + + it("does not claim failed auto-stops succeeded", function() + taskmd.shell_export = function() + return { { uuid = UUID, description = "active", start = "20260721T000000Z" } } + end + original_system = force_system_failure("stop denied") + + local notices = {} + package.loaded["taskwarrior.notify"] = setmetatable({}, { + __call = function(_, kind, message, level) + notices[#notices + 1] = { kind = kind, message = message, level = level } + end, + }) + + require("taskwarrior.granulation").stop_all_now() + + assert.are.equal(1, #notices) + assert.are.equal("error", notices[1].kind) + assert.is_truthy(notices[1].message:find("failed to auto-stop 1 task", 1, true)) + end) + + it("retains undo work after Taskwarrior rejects an undo", function() + local apply = require("taskwarrior.apply") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.b[bufnr].task_last_action_count = 2 + vim.ui.select = function(_, _, callback) callback("Undo") end + original_system = force_system_failure("undo denied") + local notices = {} + vim.notify = function(message, level) + notices[#notices + 1] = { message = message, level = level } + end + local refreshed = false + + apply.undo(bufnr, function() refreshed = true end) + + assert.are.equal(2, vim.b[bufnr].task_last_action_count) + assert.is_false(refreshed) + assert.are.equal(vim.log.levels.ERROR, notices[1].level) + assert.is_truthy(notices[1].message:find("2 still pending", 1, true)) + assert.is_truthy(notices[1].message:find("undo denied", 1, true)) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("retains only the unprocessed undo count after partial success", function() + local apply = require("taskwarrior.apply") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.b[bufnr].task_last_action_count = 3 + vim.ui.select = function(_, _, callback) callback("Undo") end + local calls = 0 + vim.fn.system = function(_) + calls = calls + 1 + original_system(calls == 1 and "true" or "false") + return calls == 1 and "" or "undo denied" + end + vim.notify = function() end + local refreshed = false + + apply.undo(bufnr, function() refreshed = true end) + + assert.are.equal(2, vim.b[bufnr].task_last_action_count) + assert.is_true(refreshed) + assert.are.equal(2, calls) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) +end)