diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eaff85..af3bd0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Rule-based received attachment configuration for cache-backed open/view behavior: + - `attachments.open`, `attachments.view`, `attachments.window`, and `attachments.cache_dir` provide the primary user-facing shorthand + - `attach.incoming.open.rules` and `attach.incoming.view.rules` remain available as the advanced rule patch API + ### Changed - Replaced deprecated `vim.loop` usage with `vim.uv` for Neovim libuv APIs. +- Received attachment open/view actions now use deterministic cache extraction and rule registries instead of top-level handler callbacks. +- The default received attachment opener now prefers `vim.ui.open()` when available and falls back to the OS opener command. ### Removed - Removed the unused internal `notmuch.float` module. Floating attachment viewing is handled directly by the attachment viewer. +- Removed top-level received attachment `open_handler` and `view_handler` configuration callbacks in favor of `attachments.open`/`attachments.view` rules, with `attach.incoming.*` available for advanced patching. +- Removed the legacy `lua/notmuch/handlers.lua` callback implementation. ## [0.4.0] - 2026-07-12 diff --git a/README.md b/README.md index 7f9c7a1..cc79879 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,11 @@ You can configure several global options to tailor the plugin's behavior: | `sync.sync_mode` | Sync display mode: `"buffer"`, `"background"`, or `"terminal"` (PTY with stdin) | `buffer` | | `queries` | Saved/pinned queries shown at top of `:Notmuch` dashboard; hidden when empty | `{}` | | `keymaps` | Configure any (WIP) command's keymap | See `config.lua`[1] | -| `open_handler` | Callback function for opening attachments | Runs OS-aware `open`[2] | -| `view_handler` | Callback function for converting attachments to text to view in floating window | See `default_view_handler()`[2] | +| `attachments.cache_dir` | Shorthand for received attachment open/view cache directory | `stdpath("cache")/notmuch.nvim/attachments` | +| `attachments.open` | List of received attachment open rules tried before defaults | `{}` | +| `attachments.view` | List of received attachment view rules tried before defaults | `{}` | +| `attachments.window` | Shorthand for floating attachment preview window options | `{ type = "float", width = 0.8, height = 0.8, border = "rounded" }` | +| `attach.incoming.*` | Advanced received attachment rule patch API | See below | | `render_html_body` | Render HTML email bodies inline using `w3m` (requires `w3m` installed) | `false` | | `thread_view_mode` | Thread view mode: `"threaded"`, `"newest-first"`, or `"oldest-first"` | `"threaded"` | | `drafts.folder` | Directory used for persistent compose/reply draft `.eml` files and JSON metadata | `stdpath("data")/notmuch.nvim/drafts` | @@ -150,7 +153,6 @@ You can configure several global options to tailor the plugin's behavior: | `suppress_deprecation_warning` | Suppress the warning shown when using deprecated notmuch API (< 0.32) | `false` | [1]: https://github.com/yousefakbar/notmuch.nvim/blob/main/lua/notmuch/config.lua -[2]: https://github.com/yousefakbar/notmuch.nvim/blob/main/lua/notmuch/handlers.lua Example configuration in plugin manager (lazy.nvim): @@ -205,43 +207,87 @@ The scratch window and commands update the same draft attachment state. Set `drafts.auto_open_attachment_window = true` if you want the scratch window to open automatically whenever a draft opens. -### Customizing Attachment Handlers +### Customizing Received Attachment Rules -The plugin provides two handlers for working with received-message attachments: +Received-message attachments use rule registries instead of monolithic handler +callbacks. Open/view actions extract the selected MIME part to +`attach.incoming.cache_dir`, build a structured attachment object, then resolve +open or view rules. Save actions still write directly to the user-selected path. -**Open Handler**: Opens attachments externally with your system's default -application. The default handler automatically detects your OS and uses `open` -(macOS), `xdg-open` (Linux), or `start` (Windows). +For everyday customization, use the `attachments` shorthand. Rules listed in +`attachments.open` and `attachments.view` are tried before the defaults. The old +received attachment `open_handler` and `view_handler` setup callbacks have been +removed; use open/view rules instead. -**View Handler**: Converts attachments to text for display in a floating window -within Neovim. The default handler supports HTML, PDF, images, Office documents, -Markdown, archives, and plain text files. It tries multiple CLI tools for each -format and falls back gracefully if tools aren't available. +```lua +require('notmuch').setup({ + attachments = { + open = { + { + name = 'pdf-zathura', + match = { ext = 'pdf' }, + command = { 'zathura', '$path' }, + detach = true, + fallback = 'Could not open PDF with zathura.', + }, + }, + view = { + { + name = 'pdf', + match = { content_type = 'application/pdf' }, + commands = { + { 'pdftotext', '-raw', '$path', '-' }, + }, + filetype = 'text', + fallback = 'Install pdftotext to preview PDFs.', + }, + }, + window = { + width = 0.9, + height = 0.9, + border = 'rounded', + }, + }, +}) +``` -To customize either handler, pass a function to `setup()`: +Advanced users can still use `attach.incoming.open.rules` and +`attach.incoming.view.rules` directly as patch tables: + +- `prepend`: try rules before defaults; +- `append`: try rules after defaults; +- `replace`: replace a default rule by name; +- `disable`: disable default rules by name. + +Example: replace the default PDF preview rule: ```lua require('notmuch').setup({ - -- Custom open handler - open_handler = function(attachment) - -- attachment.path contains the full file path - vim.fn.system({ 'my-custom-opener', attachment.path }) - end, - - -- Custom view handler - view_handler = function(attachment) - -- Must return a string to display in the floating window - local path = attachment.path - if path:match('%.pdf$') then - return vim.fn.system({ 'pdftotext', '-layout', path, '-' }) - end - return vim.fn.system({ 'cat', path }) - end, + attach = { + incoming = { + view = { + rules = { + replace = { + pdf = { + name = 'pdf', + match = { content_type = 'application/pdf' }, + commands = { + { 'pdftotext', '-raw', '$path', '-' }, + }, + filetype = 'text', + fallback = 'Install pdftotext to preview PDFs.', + }, + }, + }, + }, + }, + }, }) ``` -The default handlers are defined in `lua/notmuch/handlers.lua` and handle many -common formats out of the box. Only override them if you need specific behavior. +The default open rule prefers `vim.ui.open()` when available and falls back to +the OS opener command. Default view rules cover HTML, PDF, images, Office +documents, Markdown, archives, text, and binary fallbacks. ### Statusline Integration diff --git a/doc/notmuch.txt b/doc/notmuch.txt index 5c3a8a4..fa345e6 100644 --- a/doc/notmuch.txt +++ b/doc/notmuch.txt @@ -343,51 +343,112 @@ option will be listed with its default value. Default value: `"buffer"` -*open_handler* - Callback function for opening attachments externally with system applications. - The function receives an attachment table with a 'path' field containing the - file path. +*attachments.cache_dir* + Shorthand cache directory used when opening or viewing received-message + attachments. This maps to |attach.incoming.cache_dir|. - Default behavior: - Automatically detects your OS and uses: - - `open` on macOS - - `xdg-open` on Linux - - `start` on Windows + Default value: + `stdpath("cache")/notmuch.nvim/attachments` + +*attachments.open* + Shorthand list of received attachment open rules. These rules are prepended to + the default open rules, so they are tried before the built-in `system` rule. + Rule fields are the same as |attach.incoming.open.rules| entries. The old + received attachment `open_handler` setup callback has been removed; configure + open rules here instead. Example customization: > require('notmuch').setup({ - open_handler = function(attachment) - vim.fn.system({ 'my-opener', attachment.path }) - end, + attachments = { + open = { + { + name = 'pdf-zathura', + match = { ext = 'pdf' }, + command = { 'zathura', '$path' }, + detach = true, + fallback = 'Could not open PDF with zathura.', + }, + }, + }, }) < -*view_handler* - Callback function for converting attachments to text for display in a - floating window within Neovim. The function receives an attachment table - with a 'path' field and must return a string to display. - - Default behavior: - Supports HTML, PDF, images, Office documents, Markdown, archives, and - plain text. Tries multiple CLI tools for each format (e.g., w3m/lynx for - HTML, pdftotext/mutool for PDF) and falls back gracefully if tools aren't - available. +*attachments.view* + Shorthand list of received attachment view rules. These rules are prepended to + the default view rules. Rule fields are the same as + |attach.incoming.view.rules| entries. The old received attachment + `view_handler` setup callback has been removed; configure view rules here + instead. Example customization: > require('notmuch').setup({ - view_handler = function(attachment) - local path = attachment.path - if path:match('%.pdf$') then - return vim.fn.system({ 'pdftotext', '-layout', path, '-' }) - end - return vim.fn.system({ 'cat', path }) - end, + attachments = { + view = { + { + name = 'pdf', + match = { content_type = 'application/pdf' }, + commands = { { 'pdftotext', '-raw', '$path', '-' } }, + filetype = 'text', + fallback = 'Install pdftotext to preview PDFs.', + }, + }, + }, + }) +< + +*attachments.window* + Shorthand floating window options for received attachment previews. This maps + to |attach.incoming.view.window|. + + Default value: > + { type = 'float', width = 0.8, height = 0.8, border = 'rounded' } +< + +*attach.incoming.cache_dir* + Advanced path for the cache directory used when opening or viewing + received-message attachments. Open/view actions extract the selected MIME part + here before rule execution. Save actions still write directly to the + user-selected destination. + +*attach.incoming.open.rules* + Advanced patch table for received attachment open rules. Supported patch + fields are `prepend`, `append`, `replace`, and `disable`. The default `system` + rule prefers `vim.ui.open()` when available and falls back to the OS opener + command (`open`, `xdg-open`, or `start`). + +*attach.incoming.view.rules* + Advanced patch table for received attachment view rules. Default rules support + HTML, PDF, images, Office documents, Markdown, archives, text, and binary + fallback previews. Rules are patched with `prepend`, `append`, `replace`, and + `disable`. + + Example replacing the PDF rule: > + + require('notmuch').setup({ + attach = { + incoming = { + view = { + rules = { + replace = { + pdf = { + name = 'pdf', + match = { content_type = 'application/pdf' }, + commands = { { 'pdftotext', '-raw', '$path', '-' } }, + filetype = 'text', + fallback = 'Install pdftotext to preview PDFs.', + }, + }, + }, + }, + }, + }, }) < - Note: See lua/notmuch/handlers.lua for the complete default implementation. +*attach.incoming.view.window* + Advanced path for floating window options for received attachment previews. *keymaps* Keymap configuration table where you can override plugin defaults for all @@ -813,16 +874,14 @@ plugin's project codebase. - Handles received-message MIME part listing/opening/viewing/saving. - Supports extracting URLs from messages and following GitHub patch links. - - **handlers.lua**: - - Defines default handlers for opening and viewing attachments. - - **default_open_handler()**: OS-aware external opener using open, - xdg-open, or start depending on the platform. - - **default_view_handler()**: Converts various file formats to text for - in-buffer display. Supports HTML (w3m, lynx, elinks), PDF (pdftotext, - mutool), images (chafa, catimg, viu, exiftool), Office documents - (pandoc, docx2txt), Markdown, and archives (zip, tar). - - Users can override these handlers via |open_handler| and |view_handler| - configuration options. + - **attach/incoming/**: + - Rule-based received attachment subsystem. + - Builds structured attachment objects, extracts open/view parts to cache, + resolves configurable open/view rules, runs external openers/converters, + and renders preview buffers. + - Default open behavior prefers `vim.ui.open()` with OS command fallback. + - Default view rules cover HTML, PDF, images, Office documents, Markdown, + archives, text, and binary fallback previews. - **send.lua**: - Implements composing, replying, persistent draft opening, and sending. diff --git a/lua/notmuch/attach/incoming/attachment.lua b/lua/notmuch/attach/incoming/attachment.lua new file mode 100644 index 0000000..b967a19 --- /dev/null +++ b/lua/notmuch/attach/incoming/attachment.lua @@ -0,0 +1,78 @@ +-- notmuch.attach.incoming.attachment -- Attachment object builder + +local A = {} + +---@class NotmuchIncomingAttachment +---@field path string|nil Extracted local file path. Used for open/view flows. +---@field part NotmuchIncomingAttachmentPart Normalized MIME part metadata. +---@field message NotmuchIncomingAttachmentMessage Message metadata. + +---@class NotmuchIncomingAttachmentPart +---@field id integer|string Notmuch MIME part ID. +---@field content_type string MIME content type from notmuch metadata. +---@field filename string Attachment filename from MIME metadata. +---@field disposition string MIME content disposition, `inline`/`attachment`. +---@field size integer Size in bytes, or 0 when unknown. +---@field ext string Lowercase filename extension without leading dot. +---@field raw table Original MIME part table, from notmuch JSON output. + +---@class NotmuchIncomingAttachmentMessage +---@field id string Raw notmuch message ID without the `id:` prefix. + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function normalize_message_id(id) + return tostring(id or ''):gsub('^id:', '') +end + +local function get_ext(filename) + local ext = filename:match('%.([^%.]+)$') + return ext and ext:lower() or '' +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---From part +---@param part table Existing MimePart-like table from notmuch.attach.parts +---@param message_id string Raw message ID, preferably without `id:` prefix +---@param path string|nil Extracted local path +---@return NotmuchIncomingAttachment +function A.from_part(part, message_id, path) + if type(part) ~= 'table' then + error('notmuch.attach.incoming.attachment.from_part: part must be a table') + end + + local normalized_message_id = normalize_message_id(message_id) + if normalized_message_id == '' then + error('notmuch.attach.incoming.attachment.from_part: message_id is required') + end + + local filename = part.filename or '' + local content_type = part.content_type or part['content-type'] or 'application/octet-stream' + local disposition = part.disposition or part['content-disposition'] or 'inline' + local size = part.size or part['content-length'] or 0 + + return { + path = path, + + part = { + id = part.id, + content_type = content_type, + filename = filename, + disposition = disposition, + size = size, + ext = get_ext(filename), + raw = part, + }, + + message = { + id = normalized_message_id, + }, + } +end + +return A diff --git a/lua/notmuch/attach/incoming/defaults.lua b/lua/notmuch/attach/incoming/defaults.lua new file mode 100644 index 0000000..3fe72df --- /dev/null +++ b/lua/notmuch/attach/incoming/defaults.lua @@ -0,0 +1,221 @@ +local D = {} + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function system_open_command() + local sysname = vim.uv.os_uname().sysname + + if sysname == 'Darwin' then + return { 'open', '$path' } + elseif sysname == 'Linux' then + return { 'xdg-open', '$path' } + elseif sysname:match('Windows') then + return { 'start', '$path' } + end + + return { 'xdg-open', '$path' } +end + +local function vim_ui_open(att) + if vim.ui and vim.ui.open then + local _, err = vim.ui.open(att.path) + if err then + return nil, err + end + return true, nil + end + + return nil, 'vim.ui.open is unavailable' +end + +local function content_type(att) + return (att.part and att.part.content_type) or '' +end + +local function ext(att) + return (att.part and att.part.ext) or '' +end + +local function filename(att) + return (att.part and att.part.filename) or '' +end + +local function is_html(att) + return content_type(att) == 'text/html' or ext(att):match('^html?$') ~= nil +end + +local function is_pdf(att) + return content_type(att) == 'application/pdf' or ext(att) == 'pdf' +end + +local function is_image(att) + return content_type(att):match('^image/') ~= nil +end + +local function is_office(att) + local extension = ext(att) + return content_type(att):match('officedocument') ~= nil + or extension == 'doc' + or extension == 'docx' + or extension == 'xls' + or extension == 'xlsx' + or extension == 'ppt' + or extension == 'pptx' +end + +local function is_markdown(att) + return content_type(att) == 'text/markdown' or ext(att) == 'md' +end + +local function is_zip(att) + return content_type(att):match('zip') ~= nil or ext(att) == 'zip' +end + +local function is_tar(att) + local name = filename(att) + return content_type(att):match('tar') ~= nil + or ext(att) == 'tar' + or name:match('%.tar$') ~= nil + or name:match('%.tar%.') ~= nil +end + +local function is_text(att) + return content_type(att):match('^text/') ~= nil +end + +local function binary_fallback(att) + return string.format( + 'Unable to view binary file\nType: %s\nPath: %s', + content_type(att) ~= '' and content_type(att) or 'unknown', + att.path or '' + ) +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---Return default incoming attachment open rules. +---@return NotmuchIncomingRule[] +function D.open_rules() + return { + { + name = 'system', + match = '*', + handler = vim_ui_open, + command = system_open_command(), + detach = true, + fallback = 'Could not open attachment with the system opener', + }, + } +end + +---Return default incoming attachment view rules. +---@return NotmuchIncomingRule[] +function D.view_rules() + return { + { + name = 'html', + match = is_html, + commands = { + { 'w3m', '-T', 'text/html', '-dump', '$path' }, + { 'lynx', '-dump', '-nolist', '$path' }, + { 'elinks', '-dump', '-no-references', '$path' }, + }, + filetype = 'text', + fallback = 'HTML file (install w3m, lynx, or elinks to view)', + }, + + { + name = 'pdf', + match = is_pdf, + commands = { + { 'pdftotext', '-layout', '$path', '-' }, + { 'mutool', 'draw', '-F', 'txt', '$path' }, + }, + filetype = 'text', + fallback = 'PDF file (install pdftotext or mutool to view)', + }, + + { + name = 'image', + match = is_image, + commands = { + { 'chafa', '--size', '80x40', '$path' }, + { 'catimg', '-w', '80', '$path' }, + { 'viu', '-w', '80', '$path' }, + { 'exiftool', '$path' }, + { 'identify', '-verbose', '$path' }, + }, + filetype = 'text', + fallback = 'Image file (install chafa, viu, or exiftool to view)', + }, + + { + name = 'office', + match = is_office, + commands = { + { 'pandoc', '-t', 'plain', '$path' }, + { 'docx2txt', '$path', '-' }, + }, + filetype = 'text', + fallback = 'Office document (install pandoc or docx2txt to view)', + }, + + { + name = 'markdown', + match = is_markdown, + commands = { + { 'pandoc', '-t', 'plain', '$path' }, + { 'mdcat', '$path' }, + { 'cat', '$path' }, + }, + filetype = 'markdown', + fallback = 'Markdown file (install pandoc or mdcat to preview with formatting)', + }, + + { + name = 'zip', + match = is_zip, + commands = { + { 'unzip', '-l', '$path' }, + }, + filetype = 'text', + fallback = 'ZIP archive (install unzip to list contents)', + }, + + { + name = 'tar', + match = is_tar, + commands = { + { 'tar', '-tvf', '$path' }, + }, + filetype = 'text', + fallback = 'TAR archive (install tar to list contents)', + }, + + { + name = 'text', + match = is_text, + commands = { + { 'cat', '$path' }, + }, + filetype = 'text', + fallback = 'Unable to read text attachment', + }, + + { + name = 'binary', + match = '*', + commands = { + { 'strings', '$path' }, + }, + filetype = 'text', + fallback = binary_fallback, + }, + } +end + +return D diff --git a/lua/notmuch/attach/incoming/extractor.lua b/lua/notmuch/attach/incoming/extractor.lua new file mode 100644 index 0000000..d581ae3 --- /dev/null +++ b/lua/notmuch/attach/incoming/extractor.lua @@ -0,0 +1,178 @@ +-- notmuch.attach.incoming.extractor -- Cache/save extraction utilities + +local E = {} + +---@class NotmuchIncomingExtractorOptions +---@field cache_dir? string Cache root for incoming open/view extraction. +---@field force? boolean Re-extract even when cache file already exists. + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function default_cache_dir() + return vim.fs.joinpath(vim.fn.stdpath('cache'), 'notmuch.nvim', 'attachments') +end + +local function normalize_message_id(message_id) + return tostring(message_id or ''):gsub('^id:', '') +end + +local function fallback_filename(part) + local content_type = part.content_type or part['content-type'] or 'application/octet-stream' + local ext = content_type:match('/([%w.+-]+)$') or 'bin' + if ext == 'plain' then + ext = 'txt' + elseif ext == 'octet-stream' then + ext = 'bin' + end + return 'notmuch.' .. ext +end + +local function sanitize_component(value) + value = tostring(value or '') + value = value:gsub('^%s+', ''):gsub('%s+$', '') + value = value:gsub('[/\\]', '-') + value = value:gsub('[%z\r\n\t]', '_') + if value == '' then + return 'unknown' + end + return value +end + +local function ensure_parent_dir(path) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.mkdir(dir, 'p') == 0 and vim.fn.isdirectory(dir) == 0 then + return nil, 'failed to create directory: ' .. dir + end + return true +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---Return deterministic cache filepath. +---@param message_id string Notmuch message id, with or without `id:` prefix. +---@param part table MimePart-like table. +---@param opts NotmuchIncomingExtractorOptions|nil +---@return string|nil path Cache filepath, or nil on validation failure. +---@return string|nil err Error message when path cannot be built. +function E.cache_path(message_id, part, opts) + opts = opts or {} + + if type(part) ~= 'table' then + return nil, 'part must be a table' + end + + local normalized_id = normalize_message_id(message_id) + if normalized_id == '' then + return nil, 'message_id is required' + end + + if part.id == nil then + return nil, 'part.id is required' + end + + local cache_dir = opts.cache_dir or default_cache_dir() + local safe_message_id = sanitize_component(normalized_id) + + local filename = part.filename + if filename == nil or filename == '' then + filename = fallback_filename(part) + end + local safe_filename = sanitize_component(filename) + + return vim.fs.joinpath( + cache_dir, + safe_message_id, + tostring(part.id) .. '-' .. safe_filename + ), nil +end + +---Compute cache path, reuse if present unless force = true, otherwise extract. +---@param message_id string Notmuch message id, with or without `id:` prefix. +---@param part table MimePart-like table. +---@param opts NotmuchIncomingExtractorOptions|nil +---@return string|nil path Cached file path on success, or nil on failure. +---@return string|nil err Error message on failure. +function E.extract_to_cache(message_id, part, opts) + opts = opts or {} + + local path, err = E.cache_path(message_id, part, opts) + if not path then + return nil, err + end + + if not opts.force and vim.fn.filereadable(path) == 1 then + return path, nil + end + + return E.extract_to_path(message_id, part.id, path, opts) +end + +---Extract one notmuch MIME part directly to a path using safe process execution. +---@param message_id string Notmuch message id, with or without `id:` prefix. +---@param part_id integer|string Notmuch MIME part id. +---@param path string Destination filepath. +---@param opts NotmuchIncomingExtractorOptions|nil +---@return string|nil path Destination path on success, or nil on failure. +---@return string|nil err Error message on failure. +function E.extract_to_path(message_id, part_id, path, opts) + local normalized_id = normalize_message_id(message_id) + if normalized_id == '' then + return nil, 'message_id is required' + end + + if part_id == nil then + return nil, 'part_id is required' + end + + if not path or path == '' then + return nil, 'path is required' + end + + local ok, err = ensure_parent_dir(path) + if not ok then + return nil, err + end + + local result = vim.system({ + 'notmuch', + 'show', + '--exclude=false', + '--part=' .. tostring(part_id), + 'id:' .. normalized_id, + }, { text = false }):wait() + + if result.code ~= 0 then + return nil, result.stderr or 'notmuch extraction failed' + end + + local fd, open_err = io.open(path, 'wb') + if not fd then + return nil, open_err + end + + fd:write(result.stdout or '') + fd:close() + + return path, nil +end + +---Semantic wrapper for save flows. +---@param message_id string Notmuch message id, with or without `id:` prefix. +---@param part table MimePart-like table. +---@param path string User-selected destination filepath. +---@param opts NotmuchIncomingExtractorOptions|nil +---@return string|nil path Saved file path on success, or nil on failure. +---@return string|nil err Error message on failure. +function E.save_to_path(message_id, part, path, opts) + if type(part) ~= 'table' then + return nil, 'part must be a table' + end + + return E.extract_to_path(message_id, part.id, path, opts) +end + +return E diff --git a/lua/notmuch/attach/incoming/init.lua b/lua/notmuch/attach/incoming/init.lua new file mode 100644 index 0000000..032d8de --- /dev/null +++ b/lua/notmuch/attach/incoming/init.lua @@ -0,0 +1,65 @@ +local I = {} + +local attachment = require('notmuch.attach.incoming.attachment') +local extractor = require('notmuch.attach.incoming.extractor') +local opener = require('notmuch.attach.incoming.opener') +local viewer = require('notmuch.attach.incoming.viewer') +local renderer = require('notmuch.attach.incoming.renderer') +local config = require('notmuch.config') + +local function incoming_config() + return (((config.options or {}).attach or {}).incoming) or {} +end + +local function extract_attachment(part, message_id, incoming_opts) + local path, err = extractor.extract_to_cache(message_id, part, { + cache_dir = incoming_opts.cache_dir, + }) + if not path then + return nil, err + end + + return attachment.from_part(part, message_id, path), nil +end + +---Open an incoming MIME part externally. +---@param part table MimePart-like table. +---@param message_id string Notmuch message id, with or without `id:` prefix. +---@param opts table|nil Incoming attachment config override. +---@return boolean ok True when the attachment was opened. +---@return string|nil err Error message on failure. +function I.open_part(part, message_id, opts) + local incoming_opts = opts or incoming_config() + local att, err = extract_attachment(part, message_id, incoming_opts) + if not att then + vim.notify(err, vim.log.levels.ERROR) + return false, err + end + + return opener.open(att, incoming_opts.open or {}) +end + +---View an incoming MIME part internally. +---@param part table MimePart-like table. +---@param message_id string Notmuch message id, with or without `id:` prefix. +---@param opts table|nil Incoming attachment config override. +---@return table|nil rendered Rendered preview handles from renderer.render(). +---@return string|nil err Error message on failure. +function I.view_part(part, message_id, opts) + local incoming_opts = opts or incoming_config() + local att, err = extract_attachment(part, message_id, incoming_opts) + if not att then + vim.notify(err, vim.log.levels.ERROR) + return nil, err + end + + local result, view_err = viewer.view(att, incoming_opts.view or {}) + if not result then + vim.notify(view_err, vim.log.levels.ERROR) + return nil, view_err + end + + return renderer.render(result, att, incoming_opts.view or {}), nil +end + +return I diff --git a/lua/notmuch/attach/incoming/opener.lua b/lua/notmuch/attach/incoming/opener.lua new file mode 100644 index 0000000..ed25b8f --- /dev/null +++ b/lua/notmuch/attach/incoming/opener.lua @@ -0,0 +1,134 @@ +local O = {} + +local defaults = require('notmuch.attach.incoming.defaults') +local rules = require('notmuch.attach.incoming.rules') + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function try_handler(rule, attachment) + if type(rule.handler) ~= 'function' then + return false, 'rule handler is not a function' + end + + local ok, result, err = pcall(rule.handler, attachment) + if not ok then + return false, result + end + + if result then + return true, nil + end + + return false, err or 'handler failed' +end + +local function is_executable(cmd) + if not cmd or cmd == '' then + return false + end + + -- `start` is a Windows shell builtin used by the legacy opener fallback. + if cmd == 'start' then + return true + end + + return vim.fn.executable(cmd) == 1 +end + +local function try_command(rule, attachment) + if not rule.command then + return false, 'rule has no command' + end + + local argv, err = rules.expand_command(rule.command, attachment) + if not argv then + return false, err + end + + if not is_executable(argv[1]) then + return false, 'executable not found: ' .. tostring(argv[1]) + end + + local ok, system_err = pcall(vim.system, argv, { detach = rule.detach == true }) + if not ok then + return false, system_err + end + + return true, nil +end + +local function fallback_message(rule, attachment, err) + local fallback = rule and rule.fallback + + if type(fallback) == 'function' then + local ok, msg = pcall(fallback, attachment) + if ok and msg and msg ~= '' then + return msg + end + elseif type(fallback) == 'string' and fallback ~= '' then + return fallback + end + + return err or 'Could not open attachment' +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---Open an incoming attachment externally using configured open rules. +---@param attachment NotmuchIncomingAttachment Incoming attachment object. +---@param opts table|nil Open options/config. +---@return boolean ok True when an opener was successfully launched/handled. +---@return string|nil err Error message when no opener succeeds. +function O.open(attachment, opts) + opts = opts or {} + + if type(attachment) ~= 'table' then + local err = 'attachment must be a table' + vim.notify(err, vim.log.levels.ERROR) + return false, err + end + + local effective_rules = rules.apply_patches(defaults.open_rules(), opts.rules or {}) + local last_err + local last_rule + local matched = false + + for _, rule in ipairs(effective_rules) do + if rules.matches(rule, attachment) then + matched = true + last_rule = rule + + if rule.handler then + local ok, err = try_handler(rule, attachment) + if ok then + return true, nil + end + last_err = err + end + + if rule.command then + local ok, err = try_command(rule, attachment) + if ok then + return true, nil + end + last_err = err + end + end + end + + local message + if matched then + message = fallback_message(last_rule, attachment, last_err) + else + message = 'No open rule matched attachment' + end + + vim.notify(message, vim.log.levels.ERROR) + return false, message +end + +return O diff --git a/lua/notmuch/attach/incoming/renderer.lua b/lua/notmuch/attach/incoming/renderer.lua new file mode 100644 index 0000000..3f4bb3b --- /dev/null +++ b/lua/notmuch/attach/incoming/renderer.lua @@ -0,0 +1,109 @@ +local R = {} + +local v = vim.api + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function normalize_window_config(config) + config = config or {} + + return { + type = config.type or 'float', + width = config.width or 0.8, + height = config.height or 0.8, + border = config.border or 'rounded', + } +end + +local function resolve_dimension(value, total) + if type(value) == 'number' and value > 0 and value <= 1 then + return math.max(1, math.floor(total * value)) + end + + if type(value) == 'number' and value > 1 then + return math.floor(value) + end + + return math.max(1, math.floor(total * 0.8)) +end + +local function result_lines(result) + local content = result and result.content or '' + return vim.split(content, '\n', { plain = true }) +end + +local function default_title(result, attachment) + if result and result.title and result.title ~= '' then + return result.title + end + + local filename = attachment and attachment.part and attachment.part.filename + if filename and filename ~= '' then + return filename + end + + return 'Attachment preview' +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---Render a normalized incoming attachment view result in a Neovim window. +---@param result NotmuchIncomingViewResult View result produced by viewer.view(). +---@param attachment NotmuchIncomingAttachment|nil Incoming attachment object. +---@param opts table|nil View/render options. Uses `opts.window` when present. +---@return table rendered Rendered preview handles: `{ buf = integer, win = integer }`. +function R.render(result, attachment, opts) + opts = opts or {} + + if type(result) ~= 'table' then + error('notmuch.attach.incoming.renderer.render: result must be a table') + end + + local window = normalize_window_config(opts.window) + local buf = v.nvim_create_buf(false, true) + v.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) + + local lines = result_lines(result) + v.nvim_buf_set_lines(buf, 0, -1, false, lines) + + if result.filetype and result.filetype ~= '' then + v.nvim_set_option_value('filetype', result.filetype, { buf = buf }) + end + + local width = resolve_dimension(window.width, vim.o.columns) + local height = resolve_dimension(window.height, vim.o.lines) + width = math.min(width, math.max(1, vim.o.columns)) + height = math.min(height, math.max(1, vim.o.lines - 1)) + + local col = math.floor((vim.o.columns - width) / 2) + local row = math.floor((vim.o.lines - height) / 2) + + local win_opts = { + border = window.border, + relative = 'editor', + style = 'minimal', + height = height, + width = width, + row = row, + col = col, + title = default_title(result, attachment), + title_pos = 'center', + } + + local win = v.nvim_open_win(buf, true, win_opts) + + v.nvim_set_option_value('modifiable', false, { buf = buf }) + vim.keymap.set('n', 'q', function() + if v.nvim_win_is_valid(win) then + v.nvim_win_close(win, false) + end + end, { buffer = buf, silent = true }) + + return { buf = buf, win = win } +end + +return R diff --git a/lua/notmuch/attach/incoming/rules.lua b/lua/notmuch/attach/incoming/rules.lua new file mode 100644 index 0000000..9d442f4 --- /dev/null +++ b/lua/notmuch/attach/incoming/rules.lua @@ -0,0 +1,235 @@ +local R = {} + +---@alias NotmuchIncomingRuleMatcher +---| '"*"' +---| table +---| fun(attachment: NotmuchIncomingAttachment): boolean + +---@alias NotmuchIncomingCommand +---| table +---| fun(attachment: NotmuchIncomingAttachment): table + +---@class NotmuchIncomingRule +---@field name string Rule name used for replacement/disable patches. +---@field match NotmuchIncomingRuleMatcher Rule matcher. +---@field command? NotmuchIncomingCommand Open command definition. +---@field commands? NotmuchIncomingCommand[] View command fallback definitions. +---@field handler? fun(attachment: NotmuchIncomingAttachment): any,string|nil Custom rule handler returning a result or nil plus error. +---@field filetype? string Filetype for rendered view buffers. +---@field fallback? string|fun(attachment: NotmuchIncomingAttachment): string User-facing fallback message. +---@field detach? boolean Whether open commands should be detached processes. + +---@class NotmuchIncomingRulePatches +---@field prepend? NotmuchIncomingRule[] Rules inserted before default rules. +---@field append? NotmuchIncomingRule[] Rules inserted after default rules. +---@field replace? table Map of default rule name to replacement rule. +---@field disable? string[] Names of default rules to disable. + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function field_value(att, key) + if key == 'content_type' then + return att.part and att.part.content_type + elseif key == 'ext' then + return att.part and att.part.ext + elseif key == 'filename' then + return att.part and att.part.filename + elseif key == 'disposition' then + return att.part and att.part.disposition + elseif key == 'id' then + return att.part and att.part.id + elseif key == 'size' then + return att.part and att.part.size + elseif key == 'message_id' then + return att.message and att.message.id + end +end + +local function value_matches(expected, actual, key) + if expected == actual then + return true + end + + if key == 'content_type' and type(expected) == 'string' and expected:match('/%*$') then + local prefix = expected:gsub('/%*$', '/') + return type(actual) == 'string' and vim.startswith(actual, prefix) + end + + return false +end + +local function table_matches(match, att) + for key, expected in pairs(match) do + local actual = field_value(att, key) + if not value_matches(expected, actual, key) then + return false + end + end + + return true +end + +local function list_extend(dst, src) + for _, item in ipairs(src or {}) do + table.insert(dst, item) + end +end + +local function make_set(list) + local set = {} + for _, name in ipairs(list or {}) do + set[name] = true + end + return set +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---Return whether a rule matches an incoming attachment. +--- +---Supported matcher forms: +--- - `match = "*"` - matches all attachments +--- - `match = function(att) ... end` - uses custom Lua logic +--- - `match = { ... }` - uses structured AND semantics +--- +---@param rule NotmuchIncomingRule Rule table containing a `match` field. +---@param attachment NotmuchIncomingAttachment Incoming attachment object. +---@return boolean matches True when the rule matches the attachment. +function R.matches(rule, attachment) + if type(rule) ~= 'table' then + return false + end + + local matcher = rule.match + + if matcher == '*' then + return true + end + + if type(matcher) == 'function' then + return matcher(attachment) and true or false + end + + if type(matcher) == 'table' then + return table_matches(matcher, attachment) + end + + return false +end + +---Apply user rule patches to the default ruleset. +--- +---Patch order: +--- 1. `prepend` +--- 2. default rules after `replace`/`disable` +--- 3. `append` +--- +---Patch fields: +--- - `prepend`: rules inserted *before* the defaults. +--- - `append`: rules inserted *after* the defaults. +--- - `replace`: map of default rule name to the replacement rule. +--- - `disable`: list of default rule names to remove. +--- +---`replace` and `disable` only apply to the default rules. +--- +---@param default_rules NotmuchIncomingRule[] Default rules in their original order +---@param patches NotmuchIncomingRulePatches|nil Patch table with optional `prepend`, `append`, `replace`, and `disable` fields. +---@return NotmuchIncomingRule[] rules Effective ruleset after applying patches. +function R.apply_patches(default_rules, patches) + default_rules = default_rules or {} + patches = patches or {} + + -- Initialize the end result new ruleset + local result = {} + + -- Add the `prepend` rule sets first + list_extend(result, patches.prepend) + + -- Mark all rules in `patches.disable` to be removed from the new ruleset + local disabled = make_set(patches.disable) + local replace = patches.replace or {} + + for _, rule in ipairs(default_rules) do + local name = rule.name + + if name and disabled[name] then + -- If disabled rule, skip from being added to the new set + elseif name and replace[name] then + -- If marked for replacement and found in original set, replace with new + table.insert(result, replace[name]) + else + -- Otherwise just copy from the original to the new set + table.insert(result, rule) + end + end + + -- Add the `append` rules at the end + list_extend(result, patches.append) + + return result +end + +---Expand a command definition into an argv array. +--- +---Supported command forms: +--- - argv table, e.g. `{ "xdg-open", "$path" }` +--- - function, e.g. `function(att) return { "xdg-open", att.path } end` +--- +---Only exact `$path` argv elements are substituted. Embedded occurrences such as +---`"file=$path"` are intentionally left unchanged. +--- +---@param command NotmuchIncomingCommand Command argv table or function returning argv. +---@param attachment NotmuchIncomingAttachment Incoming attachment object. +---@return string[]|nil argv Expanded argv array, or nil on validation failure. +---@return string|nil err Error message when expansion fails. +function R.expand_command(command, attachment) + local argv + + if type(command) == 'function' then + argv = command(attachment) + elseif type(command) == 'table' then + argv = {} + for _, arg in ipairs(command) do + if arg == '$path' then + if not attachment or not attachment.path then + return nil, 'cannot expand $path: attachment.path is nil' + end + table.insert(argv, attachment.path) + else + table.insert(argv, arg) + end + end + else + return nil, 'command must be a table or function' + end + + if type(argv) ~= 'table' or #argv == 0 then + return nil, 'expanded command is empty' + end + + return argv, nil +end + +---Return the first rule that matches an incoming attachment. +--- +---Rules are checked in order using `R.matches()`. +--- +---@param ruleset NotmuchIncomingRule[] Ordered list of rules. +---@param attachment NotmuchIncomingAttachment Incoming attachment object. +---@return NotmuchIncomingRule|nil rule First matching rule, or nil if none match. +---@return integer|nil index Index of the matching rule, or nil if none match. +function R.first_match(ruleset, attachment) + for index, rule in ipairs(ruleset or {}) do + if R.matches(rule, attachment) then + return rule, index + end + end + + return nil, nil +end + +return R diff --git a/lua/notmuch/attach/incoming/viewer.lua b/lua/notmuch/attach/incoming/viewer.lua new file mode 100644 index 0000000..1500e78 --- /dev/null +++ b/lua/notmuch/attach/incoming/viewer.lua @@ -0,0 +1,203 @@ +local V = {} + +local defaults = require('notmuch.attach.incoming.defaults') +local rules = require('notmuch.attach.incoming.rules') + +---@class NotmuchIncomingViewResult +---@field content string Text content to render. +---@field filetype? string Buffer filetype to use when rendering. +---@field title? string Display title. +---@field rule? string Name of the rule that produced the result. +---@field source? string Source command/handler/fallback name if known. + +-- ----------------------------------------------------------------------------- +-- PRIVATE HELPERS +-- ----------------------------------------------------------------------------- + +local function default_title(att) + local filename = att.part and att.part.filename + if filename and filename ~= '' then + return filename + end + + local part_id = att.part and att.part.id + if part_id then + return 'part ' .. tostring(part_id) + end + + return 'attachment' +end + +local function normalize_result(value, rule, attachment, source) + if type(value) == 'table' then + return { + content = value.content or '', + filetype = value.filetype or rule.filetype or 'text', + title = value.title or default_title(attachment), + rule = value.rule or rule.name, + source = value.source or source, + }, nil + end + + if type(value) == 'string' then + return { + content = value, + filetype = rule.filetype or 'text', + title = default_title(attachment), + rule = rule.name, + source = source, + }, nil + end + + return nil, 'view result must be a table or string' +end + +local function fallback_content(rule, attachment, err) + local fallback = rule.fallback + + if type(fallback) == 'function' then + local ok, value = pcall(fallback, attachment) + if ok and value and value ~= '' then + return value + end + elseif type(fallback) == 'string' and fallback ~= '' then + return fallback + end + + return err +end + +local function fallback_result(rule, attachment, err) + local content = fallback_content(rule, attachment, err) + if not content or content == '' then + return nil, err or 'viewer failed' + end + + return { + content = content, + filetype = rule.filetype or 'text', + title = default_title(attachment), + rule = rule.name, + source = 'fallback', + }, nil +end + +local function is_executable(cmd) + return cmd and cmd ~= '' and vim.fn.executable(cmd) == 1 +end + +local function try_handler(rule, attachment) + if type(rule.handler) ~= 'function' then + return nil, 'rule handler is not a function' + end + + local ok, result, err = pcall(rule.handler, attachment) + if not ok then + return nil, result + end + + if result then + return normalize_result(result, rule, attachment, 'handler') + end + + return nil, err or 'handler failed' +end + +local function try_command(command, rule, attachment) + local argv, err = rules.expand_command(command, attachment) + if not argv then + return nil, err + end + + if not is_executable(argv[1]) then + return nil, 'executable not found: ' .. tostring(argv[1]) + end + + local ok, obj = pcall(function() + return vim.system(argv, { text = true }):wait() + end) + if not ok then + return nil, obj + end + + if obj.code ~= 0 then + return nil, obj.stderr or ('command failed: ' .. table.concat(argv, ' ')) + end + + return normalize_result(obj.stdout or '', rule, attachment, argv[1]) +end + +local function try_commands(rule, attachment) + if type(rule.commands) ~= 'table' then + return nil, 'rule has no commands' + end + + local last_err + for _, command in ipairs(rule.commands) do + local result, err = try_command(command, rule, attachment) + if result then + return result, nil + end + last_err = err + end + + return nil, last_err or 'all commands failed' +end + +-- ----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +-- ----------------------------------------------------------------------------- + +---View an incoming attachment by converting it to a normalized view result. +---@param attachment NotmuchIncomingAttachment Incoming attachment object. +---@param opts table|nil View options/config. +---@return NotmuchIncomingViewResult|nil result Normalized view result, or nil on failure. +---@return string|nil err Error message when no viewer succeeds. +function V.view(attachment, opts) + opts = opts or {} + + if type(attachment) ~= 'table' then + return nil, 'attachment must be a table' + end + + local effective_rules = rules.apply_patches(defaults.view_rules(), opts.rules or {}) + local matched = false + local last_err + + for _, rule in ipairs(effective_rules) do + if rules.matches(rule, attachment) then + matched = true + + if rule.handler then + local result, err = try_handler(rule, attachment) + if result then + return result, nil + end + last_err = err + end + + if rule.commands then + local result, err = try_commands(rule, attachment) + if result then + return result, nil + end + last_err = err + end + + local result, err = fallback_result(rule, attachment, last_err) + if result then + return result, nil + end + + last_err = err + end + end + + if not matched then + return nil, 'No view rule matched attachment' + end + + return nil, last_err or 'No viewer succeeded' +end + +return V diff --git a/lua/notmuch/attach/parts.lua b/lua/notmuch/attach/parts.lua index 3644329..82d4df1 100644 --- a/lua/notmuch/attach/parts.lua +++ b/lua/notmuch/attach/parts.lua @@ -5,7 +5,6 @@ local P = {} -------------------------------------------------------------------------------- local v = vim.api -local config = require('notmuch.config') local util = require('notmuch.util') local thread = require('notmuch.thread') @@ -316,67 +315,39 @@ function P.save_attachment_part(savedir, prompt_user) end end ---- Opens the MIME part at cursor with the configured open_handler. +--- Opens the MIME part at cursor with the incoming attachment opener. -- --- Saves the attachment to /tmp first, then passes the path to the --- open_handler callback (typically xdg-open or similar). +-- Extracts the attachment to the configured incoming attachment cache, then +-- delegates to the rule-based opener. -- ----@return nil +---@return boolean|nil ok True when opened, or nil on failure/invalid cursor. function P.open_attachment_part() - local filepath = P.save_attachment_part('/tmp', false) - - if not filepath then + local part = get_part_at_cursor() + if not part then return nil end - config.options.open_handler({ path = vim.fn.expand(filepath) }) + local id = string.match(v.nvim_buf_get_name(0), 'id:%C+') + local ok = require('notmuch.attach.incoming').open_part(part, id) + return ok or nil end --- Views the MIME part at cursor in a floating window. -- --- Saves the attachment to /tmp, processes it with view_handler, --- and displays the output in a centered floating window. +-- Extracts the attachment to the configured incoming attachment cache, converts +-- it with the rule-based viewer, and renders the result in a floating window. -- Press 'q' to close the window. -- ----@return nil +---@return table|nil rendered Rendered preview handles, or nil on failure/invalid cursor. function P.view_attachment_part() - -- Save to temp directory without prompting - local filepath = P.save_attachment_part('/tmp', false) - - -- If save fails, return early - if not filepath then + local part = get_part_at_cursor() + if not part then return nil end - -- Process with user's configured view_handler - local output = config.options.view_handler({ path = vim.fn.expand(filepath) }) - local lines = vim.split(output, '\n') - - -- Create new buffer for floating window - local buf = v.nvim_create_buf(false, true) - - -- Floating window - calculate size - local width = math.floor(vim.o.columns * 0.8) - local height = math.floor(vim.o.lines * 0.8) - local col = math.floor((vim.o.columns - width) / 2) - local row = math.floor((vim.o.lines - height) / 2) - - local win = vim.api.nvim_open_win(buf, true, { - border = "rounded", - relative = "editor", - style = "minimal", - height = height, - width = width, - row = row, - col = col, - }) - - v.nvim_buf_set_lines(buf, 0, -1, false, lines) - - v.nvim_set_option_value('modifiable', false, { buf = buf }) - vim.keymap.set('n', 'q', function() - v.nvim_win_close(win, false) - end, { buffer = buf }) + local id = string.match(v.nvim_buf_get_name(0), 'id:%C+') + local rendered = require('notmuch.attach.incoming').view_part(part, id) + return rendered end function P.get_urls_from_cursor_msg() diff --git a/lua/notmuch/config.lua b/lua/notmuch/config.lua index d5114a6..b4de05f 100644 --- a/lua/notmuch/config.lua +++ b/lua/notmuch/config.lua @@ -64,12 +64,33 @@ C.defaults = function() show_sent_drafts = false, auto_open_attachment_window = false, }, - open_handler = function(attachment) - require('notmuch.handlers').default_open_handler(attachment) - end, - view_handler = function(attachment) - return require('notmuch.handlers').default_view_handler(attachment) - end, + attach = { + incoming = { + cache_dir = vim.fs.joinpath(vim.fn.stdpath('cache'), 'notmuch.nvim', 'attachments'), + open = { + rules = { + prepend = {}, + append = {}, + replace = {}, + disable = {}, + }, + }, + view = { + rules = { + prepend = {}, + append = {}, + replace = {}, + disable = {}, + }, + window = { + type = 'float', + width = 0.8, + height = 0.8, + border = 'rounded', + }, + }, + }, + }, keymaps = { -- This should capture all notmuch.nvim related keymappings sendmail = '', attachment_window = '', @@ -78,6 +99,61 @@ C.defaults = function() return defaults end +local function normalize_attachments_config(options) + local attachments = options.attachments + if type(attachments) ~= 'table' then + return + end + + options.attach = options.attach or {} + options.attach.incoming = options.attach.incoming or {} + + local incoming = options.attach.incoming + + if attachments.cache_dir then + incoming.cache_dir = attachments.cache_dir + end + + if attachments.open then + incoming.open = incoming.open or {} + incoming.open.rules = incoming.open.rules or {} + incoming.open.rules.prepend = incoming.open.rules.prepend or {} + + if vim.islist(attachments.open) then + vim.list_extend(incoming.open.rules.prepend, attachments.open) + else + vim.notify( + 'notmuch.nvim: attachments.open must be a list of incoming attachment open rules', + vim.log.levels.WARN + ) + end + end + + if attachments.view then + incoming.view = incoming.view or {} + incoming.view.rules = incoming.view.rules or {} + incoming.view.rules.prepend = incoming.view.rules.prepend or {} + + if vim.islist(attachments.view) then + vim.list_extend(incoming.view.rules.prepend, attachments.view) + else + vim.notify( + 'notmuch.nvim: attachments.view must be a list of incoming attachment view rules', + vim.log.levels.WARN + ) + end + end + + if attachments.window then + incoming.view = incoming.view or {} + incoming.view.window = vim.tbl_deep_extend( + 'force', + incoming.view.window or {}, + attachments.window + ) + end +end + -- Setup config for `notmuch.nvim` -- -- This function sets up the configuration options which control the behavior of @@ -100,6 +176,8 @@ C.setup = function(opts) return false end + normalize_attachments_config(options) + -- If `notmuch_db_path` is set by user, expand it in case of tildes, etc. if options.notmuch_db_path then options.notmuch_db_path = vim.fn.expand(options.notmuch_db_path) @@ -107,6 +185,11 @@ C.setup = function(opts) C.options = vim.tbl_deep_extend('force', defaults, options) + -- If `attach.incoming.cache_dir` is set by user, expand it + if C.options.attach and C.options.attach.incoming and C.options.attach.incoming.cache_dir then + C.options.attach.incoming.cache_dir = vim.fn.expand(C.options.attach.incoming.cache_dir) + end + -- Validate and normalise the queries list if C.options.queries and #C.options.queries > 0 then local valid = {} diff --git a/lua/notmuch/handlers.lua b/lua/notmuch/handlers.lua deleted file mode 100644 index b018259..0000000 --- a/lua/notmuch/handlers.lua +++ /dev/null @@ -1,139 +0,0 @@ -local H = {} - ---- Default handler for opening attachments externally ----@param attachment table Table with 'path' field containing file path -H.default_open_handler = function(attachment) - local path = attachment.path - - -- If `nvim` version >=0.10.0, use `vim.ui.open()` - if vim.ui and vim.ui.open then - local _, err = vim.ui.open(path) - if err then - vim.notify('notmuch.nvim: failed to open attachment: ' .. tostring(err), vim.log.levels.ERROR) - end - return - end - - -- Detect OS and choose appropriate command - local open_cmd - local sysname = vim.uv.os_uname() - - if sysname.sysname == 'Darwin' then - open_cmd = 'open' - elseif sysname.sysname == 'Linux' then - open_cmd = 'xdg-open' - elseif sysname.sysname:match('Windows') then - open_cmd = 'start' - else - open_cmd = 'xdg-open' -- fallback - end - - -- Execute - local ok, err = pcall(vim.system, { open_cmd, path }, { detach = true }) - if not ok then - vim.notify('notmuch.nvim: failed to open attachment: ' .. tostring(err), vim.log.levels.ERROR) - end -end - ---- Default handler for viewing attachments in the floating window viewer ----@param attachment table Table with 'path' field containing file path ----@return string Text content to display in floating window -H.default_view_handler = function(attachment) - local path = attachment.path -- Already expanded, careful to escape - - -- Helper function to "try" commands in order until one works - local function try_commands(commands) - for _, cmd in ipairs(commands) do - -- Check if the tool exists - if vim.fn.executable(cmd.tool) == 1 then - local output - local success - local command = cmd.command(path) - if type(command) == 'table' then - local obj = vim.system(command):wait() - output = obj.stdout - success = (obj.code == 0) - else - output = vim.fn.system(command) - success = (vim.v.shell_error == 0) - end - - if success then - return output - end - end - end - return nil - end - - -- Detect file type - local filetype = vim.fn.system({ 'file', '--mime-type', '-b', path }):gsub('%s+$', '') - local ext = path:match('%.([^%.]+)$') or '' - - -- HTML files (most common) - if filetype:match('^text/html$') or ext:match('^html?$') then - return try_commands({ - { tool = 'w3m', command = function(p) return { 'w3m', '-T', 'text/html', '-dump', p } end }, - { tool = 'lynx', command = function(p) return { 'lynx', '-dump', '-nolist', p } end }, - { tool = 'elinks', command = function(p) return { 'elinks', '-dump', '-no-references', p } end }, - }) or "HTML file (install w3m, lynx, or elinks to view)" - end - - -- PDF files - if filetype:match('^application/pdf$') or ext == 'pdf' then - return try_commands({ - { tool = 'pdftotext', command = function(p) return { 'pdftotext', '-layout', p, '-' } end }, - { tool = 'mutool', command = function(p) return { 'mutool', 'draw', '-F', 'txt', p } end }, - }) or "PDF file (install pdftotext or mutool to view)" - end - - -- Images - if filetype:match('^image/') then - return try_commands({ - { tool = 'chafa', command = function(p) return { 'chafa', '--size', '80x40', p } end }, - { tool = 'catimg', command = function(p) return { 'catimg', '-w', '80', p } end }, - { tool = 'viu', command = function(p) return { 'viu', '-w', '80', p } end }, - { tool = 'exiftool', command = function(p) return { 'exiftool', p } end }, - { tool = 'identify', command = function(p) return { 'identify', '-verbose', p } end }, - }) or "Image file (install chafa, viu, or exiftool to view)" - end - - -- Office documents (docx, xlsx, pptx) - if filetype:match('officedocument') or ext:match('^(docx?|xlsx?|pptx?)$') then - return try_commands({ - { tool = 'pandoc', command = function(p) return { 'pandoc', '-t', 'plain', p } end }, - { tool = 'docx2txt', command = function(p) return { 'docx2txt', p, '-' } end }, - }) or "Office document (install pandoc or docx2txt to view)" - end - - -- Markdown - if filetype:match('^text/markdown$') or ext:match('^md$') then - return try_commands({ - { tool = 'pandoc', command = function(p) return { 'pandoc', '-t', 'plain', p } end }, - { tool = 'mdcat', command = function(p) return { 'mdcat', p } end }, - }) or vim.fn.system({ 'cat', path }) - end - - -- Archives (zip, tar, tar.gz, etc.) - if filetype:match('zip') or ext == 'zip' then - return vim.fn.system({ 'unzip', '-l', path }) - end - if filetype:match('tar') or ext:match('^tar%.?') then - return vim.fn.system({ 'tar', '-tvf', path }) - end - - -- Plain text (fallback for text/*) - if filetype:match('^text/') then - return vim.fn.system({ 'cat', path }) - end - - return try_commands({ - { tool = 'strings', command = function(p) return { 'strings', p } end }, - }) or string.format( - "Unable to view binary file\nType: %s\nPath: %s", - filetype, - path - ) -end - -return H diff --git a/tests/run.lua b/tests/run.lua index f11e3da..9cad2f4 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9,7 +9,6 @@ local spec_files = { "tests/specs/util_spec.lua", "tests/specs/mime_spec.lua", "tests/specs/completion_spec.lua", - "tests/specs/handlers_spec.lua", "tests/specs/attach_commands_spec.lua", "tests/specs/attach_state_spec.lua", "tests/specs/attach_scratch_spec.lua", @@ -23,6 +22,15 @@ local spec_files = { "tests/specs/tag_spec.lua", "tests/specs/refresh_spec.lua", "tests/specs/delete_spec.lua", + "tests/specs/attach_incoming_modules_spec.lua", + "tests/specs/attach_incoming_attachment_spec.lua", + "tests/specs/attach_incoming_rules_spec.lua", + "tests/specs/attach_incoming_extractor_spec.lua", + "tests/specs/attach_incoming_defaults_spec.lua", + "tests/specs/attach_incoming_opener_spec.lua", + "tests/specs/attach_incoming_viewer_spec.lua", + "tests/specs/attach_incoming_renderer_spec.lua", + "tests/specs/attach_incoming_spec.lua", -- Buffer/UI and integration coverage. "tests/specs/init_spec.lua", diff --git a/tests/specs/attach_incoming_attachment_spec.lua b/tests/specs/attach_incoming_attachment_spec.lua new file mode 100644 index 0000000..1a33480 --- /dev/null +++ b/tests/specs/attach_incoming_attachment_spec.lua @@ -0,0 +1,107 @@ +local H = dofile('tests/helpers.lua') + +return { + { + name = 'attach.incoming.attachment.from_part normalizes existing MimePart tables', + run = function() + local attachment = require('notmuch.attach.incoming.attachment') + local part = { + id = 2, + content_type = 'application/pdf', + filename = 'Report.PDF', + disposition = 'attachment', + size = 12345, + } + + local att = attachment.from_part(part, 'msg-1', '/cache/report.pdf') + + H.eq('/cache/report.pdf', att.path) + H.eq(2, att.part.id) + H.eq('application/pdf', att.part.content_type) + H.eq('Report.PDF', att.part.filename) + H.eq('attachment', att.part.disposition) + H.eq(12345, att.part.size) + H.eq('pdf', att.part.ext) + H.eq(part, att.part.raw) + H.eq('msg-1', att.message.id) + end, + }, + { + name = 'attach.incoming.attachment.from_part supports raw notmuch MIME keys', + run = function() + local attachment = require('notmuch.attach.incoming.attachment') + local part = { + id = 3, + ['content-type'] = 'text/html', + filename = 'body.html', + ['content-disposition'] = 'inline', + ['content-length'] = 99, + } + + local att = attachment.from_part(part, 'msg-2', '/cache/body.html') + + H.eq('/cache/body.html', att.path) + H.eq(3, att.part.id) + H.eq('text/html', att.part.content_type) + H.eq('body.html', att.part.filename) + H.eq('inline', att.part.disposition) + H.eq(99, att.part.size) + H.eq('html', att.part.ext) + H.eq(part, att.part.raw) + H.eq('msg-2', att.message.id) + end, + }, + { + name = 'attach.incoming.attachment.from_part applies defaults for sparse parts', + run = function() + local attachment = require('notmuch.attach.incoming.attachment') + local part = { id = 4 } + + local att = attachment.from_part(part, 'msg-3') + + H.eq(nil, att.path) + H.eq(4, att.part.id) + H.eq('application/octet-stream', att.part.content_type) + H.eq('', att.part.filename) + H.eq('inline', att.part.disposition) + H.eq(0, att.part.size) + H.eq('', att.part.ext) + H.eq(part, att.part.raw) + H.eq('msg-3', att.message.id) + end, + }, + { + name = 'attach.incoming.attachment.from_part normalizes id-prefixed message ids', + run = function() + local attachment = require('notmuch.attach.incoming.attachment') + local att = attachment.from_part({ id = 5, filename = 'note.txt' }, 'id:abc123', '/cache/note.txt') + + H.eq('abc123', att.message.id) + H.eq('txt', att.part.ext) + end, + }, + { + name = 'attach.incoming.attachment.from_part rejects invalid inputs', + run = function() + local attachment = require('notmuch.attach.incoming.attachment') + + local ok, err = pcall(function() + attachment.from_part(nil, 'msg') + end) + H.eq(false, ok) + H.contains(err, 'part must be a table') + + ok, err = pcall(function() + attachment.from_part({}, nil) + end) + H.eq(false, ok) + H.contains(err, 'message_id is required') + + ok, err = pcall(function() + attachment.from_part({}, '') + end) + H.eq(false, ok) + H.contains(err, 'message_id is required') + end, + }, +} diff --git a/tests/specs/attach_incoming_defaults_spec.lua b/tests/specs/attach_incoming_defaults_spec.lua new file mode 100644 index 0000000..cd4b14f --- /dev/null +++ b/tests/specs/attach_incoming_defaults_spec.lua @@ -0,0 +1,173 @@ +local H = dofile('tests/helpers.lua') + +local function rule_names(ruleset) + local names = {} + for _, rule in ipairs(ruleset) do + names[#names + 1] = rule.name + end + return names +end + +local function find_rule(ruleset, name) + for _, rule in ipairs(ruleset) do + if rule.name == name then + return rule + end + end + error('missing rule: ' .. name, 2) +end + +local function att(fields) + fields = fields or {} + local part = fields.part or {} + local message = fields.message or {} + + return { + path = fields.path or '/tmp/attachment', + part = { + id = part.id or 1, + content_type = part.content_type or 'application/octet-stream', + filename = part.filename or '', + disposition = part.disposition or 'attachment', + size = part.size or 0, + ext = part.ext or '', + }, + message = { + id = message.id or 'msg-1', + }, + } +end + +return { + { + name = 'attach.incoming.defaults.open_rules includes system opener with vim.ui.open handler and command fallback', + run = function() + local defaults = require('notmuch.attach.incoming.defaults') + local open_rules = defaults.open_rules() + + H.eq(1, #open_rules) + local rule = open_rules[1] + H.eq('system', rule.name) + H.eq('*', rule.match) + H.eq('function', type(rule.handler)) + H.eq(true, rule.detach) + H.contains(rule.fallback, 'Could not open attachment') + + local sysname = vim.uv.os_uname().sysname + local expected = (sysname == 'Darwin' and 'open') + or (sysname == 'Linux' and 'xdg-open') + or (sysname:match('Windows') and 'start') + or 'xdg-open' + + H.same({ expected, '$path' }, rule.command) + end, + }, + { + name = 'attach.incoming.defaults.view_rules returns expected rules in priority order', + run = function() + local defaults = require('notmuch.attach.incoming.defaults') + H.same({ + 'html', + 'pdf', + 'image', + 'office', + 'markdown', + 'zip', + 'tar', + 'text', + 'binary', + }, rule_names(defaults.view_rules())) + end, + }, + { + name = 'attach.incoming.defaults returns fresh rule tables', + run = function() + local defaults = require('notmuch.attach.incoming.defaults') + + local view_a = defaults.view_rules() + local view_b = defaults.view_rules() + view_a[1].name = 'mutated' + view_a[2].commands[1][1] = 'mutated-command' + H.eq('html', view_b[1].name) + H.eq('pdftotext', view_b[2].commands[1][1]) + + local open_a = defaults.open_rules() + local open_b = defaults.open_rules() + open_a[1].name = 'mutated' + open_a[1].command[1] = 'mutated-command' + H.eq('system', open_b[1].name) + H.eq('$path', open_b[1].command[2]) + end, + }, + { + name = 'attach.incoming.defaults.view_rules preserve default command chains', + run = function() + local defaults = require('notmuch.attach.incoming.defaults') + local view_rules = defaults.view_rules() + + H.same({ 'w3m', '-T', 'text/html', '-dump', '$path' }, find_rule(view_rules, 'html').commands[1]) + H.same({ 'lynx', '-dump', '-nolist', '$path' }, find_rule(view_rules, 'html').commands[2]) + H.same({ 'elinks', '-dump', '-no-references', '$path' }, find_rule(view_rules, 'html').commands[3]) + + H.same({ 'pdftotext', '-layout', '$path', '-' }, find_rule(view_rules, 'pdf').commands[1]) + H.same({ 'mutool', 'draw', '-F', 'txt', '$path' }, find_rule(view_rules, 'pdf').commands[2]) + + H.same({ 'chafa', '--size', '80x40', '$path' }, find_rule(view_rules, 'image').commands[1]) + H.same({ 'catimg', '-w', '80', '$path' }, find_rule(view_rules, 'image').commands[2]) + H.same({ 'viu', '-w', '80', '$path' }, find_rule(view_rules, 'image').commands[3]) + H.same({ 'exiftool', '$path' }, find_rule(view_rules, 'image').commands[4]) + H.same({ 'identify', '-verbose', '$path' }, find_rule(view_rules, 'image').commands[5]) + + H.same({ 'pandoc', '-t', 'plain', '$path' }, find_rule(view_rules, 'office').commands[1]) + H.same({ 'docx2txt', '$path', '-' }, find_rule(view_rules, 'office').commands[2]) + + H.same({ 'pandoc', '-t', 'plain', '$path' }, find_rule(view_rules, 'markdown').commands[1]) + H.same({ 'mdcat', '$path' }, find_rule(view_rules, 'markdown').commands[2]) + H.same({ 'cat', '$path' }, find_rule(view_rules, 'markdown').commands[3]) + + H.same({ 'unzip', '-l', '$path' }, find_rule(view_rules, 'zip').commands[1]) + H.same({ 'tar', '-tvf', '$path' }, find_rule(view_rules, 'tar').commands[1]) + H.same({ 'cat', '$path' }, find_rule(view_rules, 'text').commands[1]) + H.same({ 'strings', '$path' }, find_rule(view_rules, 'binary').commands[1]) + end, + }, + { + name = 'attach.incoming.defaults.view_rules match representative attachments', + run = function() + local defaults = require('notmuch.attach.incoming.defaults') + local rules = require('notmuch.attach.incoming.rules') + local view_rules = defaults.view_rules() + + H.eq(true, rules.matches(find_rule(view_rules, 'html'), att({ part = { content_type = 'text/html', filename = 'body.html', ext = 'html' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'pdf'), att({ part = { content_type = 'application/pdf', filename = 'doc.pdf', ext = 'pdf' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'pdf'), att({ part = { content_type = 'application/octet-stream', filename = 'doc.pdf', ext = 'pdf' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'image'), att({ part = { content_type = 'image/png', filename = 'img.png', ext = 'png' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'office'), att({ part = { content_type = 'application/octet-stream', filename = 'doc.docx', ext = 'docx' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'markdown'), att({ part = { content_type = 'text/markdown', filename = 'README.md', ext = 'md' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'zip'), att({ part = { content_type = 'application/zip', filename = 'archive.zip', ext = 'zip' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'tar'), att({ part = { content_type = 'application/gzip', filename = 'archive.tar.gz', ext = 'gz' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'text'), att({ part = { content_type = 'text/plain', filename = 'note.txt', ext = 'txt' } }))) + H.eq(true, rules.matches(find_rule(view_rules, 'binary'), att({ part = { content_type = 'application/octet-stream', filename = 'blob.bin', ext = 'bin' } }))) + end, + }, + { + name = 'attach.incoming.defaults.view_rules expose fallback messages', + run = function() + local defaults = require('notmuch.attach.incoming.defaults') + local view_rules = defaults.view_rules() + + H.contains(find_rule(view_rules, 'html').fallback, 'HTML file') + H.contains(find_rule(view_rules, 'pdf').fallback, 'PDF file') + H.contains(find_rule(view_rules, 'image').fallback, 'Image file') + H.eq('function', type(find_rule(view_rules, 'binary').fallback)) + + local fallback = find_rule(view_rules, 'binary').fallback(att({ + path = '/tmp/blob.bin', + part = { content_type = 'application/octet-stream' }, + })) + H.contains(fallback, 'Unable to view binary file') + H.contains(fallback, 'application/octet-stream') + H.contains(fallback, '/tmp/blob.bin') + end, + }, +} diff --git a/tests/specs/attach_incoming_extractor_spec.lua b/tests/specs/attach_incoming_extractor_spec.lua new file mode 100644 index 0000000..68c72c1 --- /dev/null +++ b/tests/specs/attach_incoming_extractor_spec.lua @@ -0,0 +1,222 @@ +local H = dofile('tests/helpers.lua') + +local function read_file(path) + local fd = assert(io.open(path, 'rb')) + local data = fd:read('*a') + fd:close() + return data +end + +local function with_mocked_system(result, fn) + local old_system = vim.system + local calls = {} + + vim.system = function(cmd, opts) + calls[#calls + 1] = { cmd = cmd, opts = opts } + return { + wait = function() + return result + end, + } + end + + local ok, err = pcall(fn, calls) + vim.system = old_system + if not ok then error(err, 0) end +end + +return { + { + name = 'attach.incoming.extractor.cache_path builds deterministic sanitized paths', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local path, err = extractor.cache_path('id:abc/123', { + id = 2, + filename = '../unsafe/name.pdf', + content_type = 'application/pdf', + }, { cache_dir = '/tmp/cache' }) + + H.eq(nil, err) + H.eq(vim.fs.joinpath('/tmp/cache', 'abc-123', '2-..-unsafe-name.pdf'), path) + end, + }, + { + name = 'attach.incoming.extractor.cache_path uses fallback filenames from content type', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local path, err = extractor.cache_path('msg1', { + id = 1, + filename = '', + content_type = 'text/plain', + }, { cache_dir = '/tmp/cache' }) + + H.eq(nil, err) + H.eq(vim.fs.joinpath('/tmp/cache', 'msg1', '1-notmuch.txt'), path) + + path, err = extractor.cache_path('msg1', { + id = 2, + content_type = 'application/octet-stream', + }, { cache_dir = '/tmp/cache' }) + + H.eq(nil, err) + H.eq(vim.fs.joinpath('/tmp/cache', 'msg1', '2-notmuch.bin'), path) + end, + }, + { + name = 'attach.incoming.extractor.cache_path reports validation errors', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local path, err + + path, err = extractor.cache_path('msg1', nil, { cache_dir = '/tmp/cache' }) + H.eq(nil, path) + H.contains(err, 'part must be a table') + + path, err = extractor.cache_path(nil, { id = 1 }, { cache_dir = '/tmp/cache' }) + H.eq(nil, path) + H.contains(err, 'message_id is required') + + path, err = extractor.cache_path('msg1', {}, { cache_dir = '/tmp/cache' }) + H.eq(nil, path) + H.contains(err, 'part.id is required') + end, + }, + { + name = 'attach.incoming.extractor.extract_to_path runs notmuch argv and writes stdout', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local dir = H.tmpdir() + local out = vim.fs.joinpath(dir, 'nested', 'out.txt') + + with_mocked_system({ code = 0, stdout = 'hello attachment', stderr = '' }, function(calls) + local saved, err = extractor.extract_to_path('id:msg1', 3, out) + + H.eq(out, saved) + H.eq(nil, err) + H.eq(1, #calls) + H.same({ + 'notmuch', + 'show', + '--exclude=false', + '--part=3', + 'id:msg1', + }, calls[1].cmd) + H.same({ text = false }, calls[1].opts) + H.eq('hello attachment', read_file(out)) + end) + end, + }, + { + name = 'attach.incoming.extractor.extract_to_path reports notmuch failures', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local dir = H.tmpdir() + local out = vim.fs.joinpath(dir, 'out.txt') + + with_mocked_system({ code = 1, stdout = '', stderr = 'boom' }, function() + local saved, err = extractor.extract_to_path('msg1', 4, out) + + H.eq(nil, saved) + H.contains(err, 'boom') + H.eq(0, vim.fn.filereadable(out)) + end) + end, + }, + { + name = 'attach.incoming.extractor.extract_to_path validates required inputs', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local dir = H.tmpdir() + local out = vim.fs.joinpath(dir, 'out.txt') + local saved, err + + saved, err = extractor.extract_to_path(nil, 1, out) + H.eq(nil, saved) + H.contains(err, 'message_id is required') + + saved, err = extractor.extract_to_path('msg1', nil, out) + H.eq(nil, saved) + H.contains(err, 'part_id is required') + + saved, err = extractor.extract_to_path('msg1', 1, '') + H.eq(nil, saved) + H.contains(err, 'path is required') + end, + }, + { + name = 'attach.incoming.extractor.extract_to_cache reuses existing cached files', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local dir = H.tmpdir() + local part = { id = 2, filename = 'doc.txt', content_type = 'text/plain' } + local expected = assert(extractor.cache_path('msg1', part, { cache_dir = dir })) + vim.fn.mkdir(vim.fn.fnamemodify(expected, ':h'), 'p') + H.write_file(expected, 'cached') + + local old_system = vim.system + local called = false + vim.system = function() + called = true + error('vim.system should not be called for cached extraction') + end + + local ok, err = pcall(function() + local path, extract_err = extractor.extract_to_cache('msg1', part, { cache_dir = dir }) + H.eq(expected, path) + H.eq(nil, extract_err) + H.eq(false, called) + H.eq('cached', read_file(expected)) + end) + + vim.system = old_system + if not ok then error(err, 0) end + end, + }, + { + name = 'attach.incoming.extractor.extract_to_cache force re-extracts cached files', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local dir = H.tmpdir() + local part = { id = 2, filename = 'doc.txt', content_type = 'text/plain' } + local expected = assert(extractor.cache_path('msg1', part, { cache_dir = dir })) + vim.fn.mkdir(vim.fn.fnamemodify(expected, ':h'), 'p') + H.write_file(expected, 'old cached') + + with_mocked_system({ code = 0, stdout = 'new cached', stderr = '' }, function(calls) + local path, err = extractor.extract_to_cache('msg1', part, { cache_dir = dir, force = true }) + + H.eq(expected, path) + H.eq(nil, err) + H.eq(1, #calls) + H.eq('new cached', read_file(expected)) + end) + end, + }, + { + name = 'attach.incoming.extractor.save_to_path validates part and extracts to requested path', + run = function() + local extractor = require('notmuch.attach.incoming.extractor') + local dir = H.tmpdir() + local out = vim.fs.joinpath(dir, 'saved.txt') + local saved, err = extractor.save_to_path('msg1', nil, out) + + H.eq(nil, saved) + H.contains(err, 'part must be a table') + + with_mocked_system({ code = 0, stdout = 'saved body', stderr = '' }, function(calls) + saved, err = extractor.save_to_path('id:msg1', { id = 5 }, out) + + H.eq(out, saved) + H.eq(nil, err) + H.same({ + 'notmuch', + 'show', + '--exclude=false', + '--part=5', + 'id:msg1', + }, calls[1].cmd) + H.eq('saved body', read_file(out)) + end) + end, + }, +} diff --git a/tests/specs/attach_incoming_modules_spec.lua b/tests/specs/attach_incoming_modules_spec.lua new file mode 100644 index 0000000..4d05004 --- /dev/null +++ b/tests/specs/attach_incoming_modules_spec.lua @@ -0,0 +1,40 @@ +local H = dofile('tests/helpers.lua') + +return { + { + name = 'attach.incoming modules load and expose planned APIs', + run = function() + local incoming = require('notmuch.attach.incoming') + local attachment = require('notmuch.attach.incoming.attachment') + local extractor = require('notmuch.attach.incoming.extractor') + local rules = require('notmuch.attach.incoming.rules') + local defaults = require('notmuch.attach.incoming.defaults') + local viewer = require('notmuch.attach.incoming.viewer') + local opener = require('notmuch.attach.incoming.opener') + local renderer = require('notmuch.attach.incoming.renderer') + + H.eq('function', type(incoming.open_part)) + H.eq('function', type(incoming.view_part)) + + H.eq('function', type(attachment.from_part)) + + H.eq('function', type(extractor.cache_path)) + H.eq('function', type(extractor.extract_to_cache)) + H.eq('function', type(extractor.extract_to_path)) + + H.eq('function', type(rules.matches)) + H.eq('function', type(rules.apply_patches)) + H.eq('function', type(rules.expand_command)) + H.eq('function', type(rules.first_match)) + + H.eq('function', type(defaults.open_rules)) + H.eq('function', type(defaults.view_rules)) + H.eq('table', type(defaults.open_rules())) + H.eq('table', type(defaults.view_rules())) + + H.eq('function', type(viewer.view)) + H.eq('function', type(opener.open)) + H.eq('function', type(renderer.render)) + end, + }, +} diff --git a/tests/specs/attach_incoming_opener_spec.lua b/tests/specs/attach_incoming_opener_spec.lua new file mode 100644 index 0000000..d5e2555 --- /dev/null +++ b/tests/specs/attach_incoming_opener_spec.lua @@ -0,0 +1,309 @@ +local H = dofile('tests/helpers.lua') + +local function attachment() + return { + path = '/tmp/doc.pdf', + part = { + id = 1, + content_type = 'application/pdf', + filename = 'doc.pdf', + ext = 'pdf', + disposition = 'attachment', + size = 123, + }, + message = { id = 'msg1' }, + } +end + +local function expected_system_opener() + local sysname = vim.uv.os_uname().sysname + return (sysname == 'Darwin' and 'open') + or (sysname == 'Linux' and 'xdg-open') + or (sysname:match('Windows') and 'start') + or 'xdg-open' +end + +local function with_open_mocks(mocks, fn) + local old_ui_open = vim.ui.open + local old_system = vim.system + local old_executable = vim.fn.executable + local old_notify = vim.notify + + if mocks.ui_open_set then + vim.ui.open = mocks.ui_open + end + if mocks.system then + vim.system = mocks.system + end + if mocks.executable then + vim.fn.executable = mocks.executable + end + if mocks.notify then + vim.notify = mocks.notify + end + + local ok, err = pcall(fn) + + vim.ui.open = old_ui_open + vim.system = old_system + vim.fn.executable = old_executable + vim.notify = old_notify + + if not ok then error(err, 0) end +end + +return { + { + name = 'attach.incoming.opener.open uses vim.ui.open default opener when available', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local opened + + with_open_mocks({ + ui_open_set = true, + ui_open = function(path) + opened = path + return { pid = 123 }, nil + end, + system = function() + error('vim.system should not be called when vim.ui.open succeeds') + end, + }, function() + local ok, err = opener.open(attachment()) + H.eq(true, ok) + H.eq(nil, err) + H.eq('/tmp/doc.pdf', opened) + end) + end, + }, + { + name = 'attach.incoming.opener.open falls back to command when vim.ui.open is unavailable', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local captured_cmd, captured_opts + + with_open_mocks({ + ui_open_set = true, + ui_open = nil, + executable = function() + return 1 + end, + system = function(cmd, opts) + captured_cmd = cmd + captured_opts = opts + end, + }, function() + local ok, err = opener.open(attachment()) + H.eq(true, ok) + H.eq(nil, err) + H.same({ expected_system_opener(), '/tmp/doc.pdf' }, captured_cmd) + H.same({ detach = true }, captured_opts) + end) + end, + }, + { + name = 'attach.incoming.opener.open falls back to command when vim.ui.open returns an error', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local captured_cmd + + with_open_mocks({ + ui_open_set = true, + ui_open = function() + return nil, 'ui failed' + end, + executable = function() + return 1 + end, + system = function(cmd) + captured_cmd = cmd + end, + }, function() + local ok, err = opener.open(attachment()) + H.eq(true, ok) + H.eq(nil, err) + H.same({ expected_system_opener(), '/tmp/doc.pdf' }, captured_cmd) + end) + end, + }, + { + name = 'attach.incoming.opener.open applies user replacement rules', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local captured_cmd, captured_opts + + with_open_mocks({ + ui_open_set = true, + ui_open = function() + error('replaced system rule should not call default vim.ui.open handler') + end, + executable = function(cmd) + return cmd == 'custom-open' and 1 or 0 + end, + system = function(cmd, opts) + captured_cmd = cmd + captured_opts = opts + end, + }, function() + local ok, err = opener.open(attachment(), { + rules = { + replace = { + system = { + name = 'system', + match = '*', + command = { 'custom-open', '$path' }, + detach = true, + }, + }, + }, + }) + + H.eq(true, ok) + H.eq(nil, err) + H.same({ 'custom-open', '/tmp/doc.pdf' }, captured_cmd) + H.same({ detach = true }, captured_opts) + end) + end, + }, + { + name = 'attach.incoming.opener.open reports unavailable commands', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local note + + with_open_mocks({ + ui_open_set = true, + ui_open = nil, + executable = function() + return 0 + end, + system = function() + error('vim.system should not be called for unavailable executable') + end, + notify = function(msg, level) + note = { msg = msg, level = level } + end, + }, function() + local ok, err = opener.open(attachment()) + H.eq(false, ok) + H.contains(err, 'Could not open attachment') + H.contains(note.msg, 'Could not open attachment') + H.eq(vim.log.levels.ERROR, note.level) + end) + end, + }, + { + name = 'attach.incoming.opener.open falls through failed matching rules', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local captured_cmd + + with_open_mocks({ + ui_open_set = true, + ui_open = nil, + executable = function(cmd) + return cmd == 'good-open' and 1 or 0 + end, + system = function(cmd) + captured_cmd = cmd + end, + }, function() + local ok, err = opener.open(attachment(), { + rules = { + prepend = { + { name = 'bad', match = '*', command = { 'missing-open', '$path' } }, + { name = 'good', match = '*', command = { 'good-open', '$path' } }, + }, + disable = { 'system' }, + }, + }) + + H.eq(true, ok) + H.eq(nil, err) + H.same({ 'good-open', '/tmp/doc.pdf' }, captured_cmd) + end) + end, + }, + { + name = 'attach.incoming.opener.open recovers from handler errors with command fallback', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local captured_cmd + + with_open_mocks({ + executable = function(cmd) + return cmd == 'good-open' and 1 or 0 + end, + system = function(cmd) + captured_cmd = cmd + end, + }, function() + local ok, err = opener.open(attachment(), { + rules = { + prepend = { + { + name = 'handler-error', + match = '*', + handler = function() + error('boom') + end, + command = { 'good-open', '$path' }, + }, + }, + disable = { 'system' }, + }, + }) + + H.eq(true, ok) + H.eq(nil, err) + H.same({ 'good-open', '/tmp/doc.pdf' }, captured_cmd) + end) + end, + }, + { + name = 'attach.incoming.opener.open validates attachment input', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local note + + with_open_mocks({ + notify = function(msg, level) + note = { msg = msg, level = level } + end, + }, function() + local ok, err = opener.open(nil, {}) + H.eq(false, ok) + H.contains(err, 'attachment must be a table') + H.contains(note.msg, 'attachment must be a table') + H.eq(vim.log.levels.ERROR, note.level) + end) + end, + }, + { + name = 'attach.incoming.opener.open reports when no rules match', + run = function() + local opener = require('notmuch.attach.incoming.opener') + local note + + with_open_mocks({ + notify = function(msg, level) + note = { msg = msg, level = level } + end, + }, function() + local ok, err = opener.open(attachment(), { + rules = { + prepend = { + { name = 'txt-only', match = { ext = 'txt' }, command = { 'open-text', '$path' } }, + }, + disable = { 'system' }, + }, + }) + + H.eq(false, ok) + H.contains(err, 'No open rule matched attachment') + H.contains(note.msg, 'No open rule matched attachment') + H.eq(vim.log.levels.ERROR, note.level) + end) + end, + }, +} diff --git a/tests/specs/attach_incoming_renderer_spec.lua b/tests/specs/attach_incoming_renderer_spec.lua new file mode 100644 index 0000000..a5a101c --- /dev/null +++ b/tests/specs/attach_incoming_renderer_spec.lua @@ -0,0 +1,95 @@ +local H = dofile('tests/helpers.lua') + +local function attachment() + return { + path = '/tmp/doc.md', + part = { + id = 1, + content_type = 'text/markdown', + filename = 'doc.md', + ext = 'md', + }, + message = { id = 'msg1' }, + } +end + +return { + { + name = 'attach.incoming.renderer.render creates preview buffer and window', + run = function() + local renderer = require('notmuch.attach.incoming.renderer') + local rendered = renderer.render({ + content = 'line one\nline two', + filetype = 'markdown', + title = 'Preview Title', + }, attachment(), { + window = { + width = 40, + height = 10, + border = 'single', + }, + }) + + H.ok(vim.api.nvim_buf_is_valid(rendered.buf)) + H.ok(vim.api.nvim_win_is_valid(rendered.win)) + H.same({ 'line one', 'line two' }, vim.api.nvim_buf_get_lines(rendered.buf, 0, -1, false)) + H.eq('markdown', vim.api.nvim_get_option_value('filetype', { buf = rendered.buf })) + H.eq(false, vim.api.nvim_get_option_value('modifiable', { buf = rendered.buf })) + + local cfg = vim.api.nvim_win_get_config(rendered.win) + H.eq(40, cfg.width) + H.eq(10, cfg.height) + H.eq('table', type(cfg.border)) + + vim.api.nvim_win_close(rendered.win, true) + end, + }, + { + name = 'attach.incoming.renderer.render supports percentage dimensions and fallback title', + run = function() + local renderer = require('notmuch.attach.incoming.renderer') + local rendered = renderer.render({ + content = 'body', + filetype = 'text', + }, attachment(), { + window = { + width = 0.5, + height = 0.5, + border = 'rounded', + }, + }) + + local cfg = vim.api.nvim_win_get_config(rendered.win) + H.eq(math.floor(vim.o.columns * 0.5), cfg.width) + H.eq(math.floor(vim.o.lines * 0.5), cfg.height) + H.eq('table', type(cfg.border)) + + vim.api.nvim_win_close(rendered.win, true) + end, + }, + { + name = 'attach.incoming.renderer.render q mapping closes preview window', + run = function() + local renderer = require('notmuch.attach.incoming.renderer') + local rendered = renderer.render({ content = 'closable', filetype = 'text' }, attachment()) + + local map = vim.fn.maparg('q', 'n', false, true) + H.eq(1, map.buffer) + H.eq('function', type(map.callback)) + map.callback() + H.eq(false, vim.api.nvim_win_is_valid(rendered.win)) + end, + }, + { + name = 'attach.incoming.renderer.render validates result input', + run = function() + local renderer = require('notmuch.attach.incoming.renderer') + local ok, err = pcall(function() + renderer.render(nil, attachment()) + end) + + H.eq(false, ok) + H.contains(err, 'result must be a table') + end, + }, +} diff --git a/tests/specs/attach_incoming_rules_spec.lua b/tests/specs/attach_incoming_rules_spec.lua new file mode 100644 index 0000000..5f18f31 --- /dev/null +++ b/tests/specs/attach_incoming_rules_spec.lua @@ -0,0 +1,250 @@ +local H = dofile('tests/helpers.lua') + +local function attachment(overrides) + local att = { + path = '/tmp/doc.pdf', + part = { + id = 2, + content_type = 'application/pdf', + filename = 'doc.pdf', + disposition = 'attachment', + size = 123, + ext = 'pdf', + }, + message = { + id = 'msg-1', + }, + } + + overrides = overrides or {} + for key, value in pairs(overrides) do + if key == 'part' then + for part_key, part_value in pairs(value) do + att.part[part_key] = part_value + end + elseif key == 'message' then + for msg_key, msg_value in pairs(value) do + att.message[msg_key] = msg_value + end + else + att[key] = value + end + end + + return att +end + +local function rule_names(ruleset) + local names = {} + for _, rule in ipairs(ruleset) do + names[#names + 1] = rule.name + end + return names +end + +return { + { + name = 'attach.incoming.rules.matches supports wildcard matchers', + run = function() + local rules = require('notmuch.attach.incoming.rules') + H.eq(true, rules.matches({ name = 'all', match = '*' }, attachment())) + H.eq(false, rules.matches({ name = 'none' }, attachment())) + H.eq(false, rules.matches(nil, attachment())) + end, + }, + { + name = 'attach.incoming.rules.matches uses AND semantics for structured matchers', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local att = attachment() + + H.eq(true, rules.matches({ + name = 'pdf', + match = { + content_type = 'application/pdf', + ext = 'pdf', + filename = 'doc.pdf', + disposition = 'attachment', + id = 2, + size = 123, + message_id = 'msg-1', + }, + }, att)) + + H.eq(false, rules.matches({ + name = 'not-pdf', + match = { + content_type = 'application/pdf', + ext = 'txt', + }, + }, att)) + + H.eq(false, rules.matches({ + name = 'unknown-key', + match = { + unknown = 'value', + }, + }, att)) + end, + }, + { + name = 'attach.incoming.rules.matches supports function matchers', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local att = attachment() + + H.eq(true, rules.matches({ + name = 'function-true', + match = function(candidate) + return candidate.part.filename == 'doc.pdf' + end, + }, att)) + + H.eq(false, rules.matches({ + name = 'function-false', + match = function(candidate) + return candidate.part.filename == 'other.pdf' + end, + }, att)) + end, + }, + { + name = 'attach.incoming.rules.matches supports MIME prefix wildcards', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local image = attachment({ part = { content_type = 'image/png', ext = 'png', filename = 'img.png' } }) + local pdf = attachment() + + H.eq(true, rules.matches({ name = 'image', match = { content_type = 'image/*' } }, image)) + H.eq(false, rules.matches({ name = 'image', match = { content_type = 'image/*' } }, pdf)) + end, + }, + { + name = 'attach.incoming.rules.first_match returns first matching rule and index', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local att = attachment() + local matched, index = rules.first_match({ + { name = 'txt', match = { ext = 'txt' } }, + { name = 'pdf', match = { ext = 'pdf' } }, + { name = 'all', match = '*' }, + }, att) + + H.eq('pdf', matched.name) + H.eq(2, index) + + matched, index = rules.first_match({ + { name = 'txt', match = { ext = 'txt' } }, + }, att) + H.eq(nil, matched) + H.eq(nil, index) + end, + }, + { + name = 'attach.incoming.rules.expand_command expands static argv commands', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local argv, err = rules.expand_command({ 'pdftotext', '$path', '-' }, attachment()) + + H.eq(nil, err) + H.same({ 'pdftotext', '/tmp/doc.pdf', '-' }, argv) + end, + }, + { + name = 'attach.incoming.rules.expand_command does not expand embedded placeholders', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local argv, err = rules.expand_command({ 'echo', 'file=$path' }, attachment()) + + H.eq(nil, err) + H.same({ 'echo', 'file=$path' }, argv) + end, + }, + { + name = 'attach.incoming.rules.expand_command supports function commands', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local argv, err = rules.expand_command(function(att) + return { 'open', att.path } + end, attachment()) + + H.eq(nil, err) + H.same({ 'open', '/tmp/doc.pdf' }, argv) + end, + }, + { + name = 'attach.incoming.rules.expand_command reports validation errors', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local argv, err + + argv, err = rules.expand_command('xdg-open', attachment()) + H.eq(nil, argv) + H.contains(err, 'command must be a table or function') + + local missing_path = attachment() + missing_path.path = nil + argv, err = rules.expand_command({ 'xdg-open', '$path' }, missing_path) + H.eq(nil, argv) + H.contains(err, 'attachment.path is nil') + + argv, err = rules.expand_command(function() + return 'not-a-table' + end, attachment()) + H.eq(nil, argv) + H.contains(err, 'expanded command is empty') + + argv, err = rules.expand_command({}, attachment()) + H.eq(nil, argv) + H.contains(err, 'expanded command is empty') + end, + }, + { + name = 'attach.incoming.rules.apply_patches applies prepend replace disable and append in order', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local defaults = { + { name = 'html', match = { content_type = 'text/html' } }, + { name = 'pdf', match = { content_type = 'application/pdf' } }, + { name = 'text', match = { content_type = 'text/*' } }, + } + + local effective = rules.apply_patches(defaults, { + prepend = { + { name = 'custom-first', match = '*' }, + }, + replace = { + pdf = { name = 'pdf-custom', match = { ext = 'pdf' } }, + missing = { name = 'missing-custom', match = '*' }, + }, + disable = { 'html' }, + append = { + { name = 'custom-last', match = '*' }, + }, + }) + + H.same({ 'custom-first', 'pdf-custom', 'text', 'custom-last' }, rule_names(effective)) + end, + }, + { + name = 'attach.incoming.rules.apply_patches does not mutate default rules', + run = function() + local rules = require('notmuch.attach.incoming.rules') + local defaults = { + { name = 'html' }, + { name = 'pdf' }, + { name = 'text' }, + } + + local effective = rules.apply_patches(defaults, { + replace = { + pdf = { name = 'pdf-custom' }, + }, + disable = { 'html' }, + }) + + H.same({ 'html', 'pdf', 'text' }, rule_names(defaults)) + H.same({ 'pdf-custom', 'text' }, rule_names(effective)) + end, + }, +} diff --git a/tests/specs/attach_incoming_spec.lua b/tests/specs/attach_incoming_spec.lua new file mode 100644 index 0000000..edfd05b --- /dev/null +++ b/tests/specs/attach_incoming_spec.lua @@ -0,0 +1,126 @@ +local H = dofile('tests/helpers.lua') + +local function part() + return { + id = 2, + content_type = 'application/pdf', + filename = 'doc.pdf', + disposition = 'attachment', + size = 123, + } +end + +return { + { + name = 'attach.incoming.open_part extracts builds attachment and opens it', + run = function() + local incoming = require('notmuch.attach.incoming') + local extractor = require('notmuch.attach.incoming.extractor') + local opener = require('notmuch.attach.incoming.opener') + local old_extract, old_open = extractor.extract_to_cache, opener.open + local opened + + extractor.extract_to_cache = function(message_id, selected, opts) + H.eq('id:msg1', message_id) + H.eq(2, selected.id) + H.eq('/cache', opts.cache_dir) + return '/cache/msg1/2-doc.pdf', nil + end + opener.open = function(att, opts) + opened = { att = att, opts = opts } + return true, nil + end + + local ok, err = pcall(function() + local success, open_err = incoming.open_part(part(), 'id:msg1', { + cache_dir = '/cache', + open = { rules = { disable = { 'system' } } }, + }) + H.eq(true, success) + H.eq(nil, open_err) + H.eq('/cache/msg1/2-doc.pdf', opened.att.path) + H.eq('msg1', opened.att.message.id) + H.eq('application/pdf', opened.att.part.content_type) + H.same({ disable = { 'system' } }, opened.opts.rules) + end) + + extractor.extract_to_cache, opener.open = old_extract, old_open + if not ok then error(err, 0) end + end, + }, + { + name = 'attach.incoming.view_part extracts views and renders attachment', + run = function() + local incoming = require('notmuch.attach.incoming') + local extractor = require('notmuch.attach.incoming.extractor') + local viewer = require('notmuch.attach.incoming.viewer') + local renderer = require('notmuch.attach.incoming.renderer') + local old_extract, old_view, old_render = extractor.extract_to_cache, viewer.view, renderer.render + local rendered + + extractor.extract_to_cache = function() + return '/cache/msg1/2-doc.pdf', nil + end + viewer.view = function(att, opts) + H.eq('/cache/msg1/2-doc.pdf', att.path) + H.same({ disable = { 'pdf' } }, opts.rules) + return { content = 'preview', filetype = 'text', title = 'doc.pdf' }, nil + end + renderer.render = function(result, att, opts) + rendered = { result = result, att = att, opts = opts } + return { buf = 10, win = 20 } + end + + local ok, err = pcall(function() + local handles, view_err = incoming.view_part(part(), 'msg1', { + cache_dir = '/cache', + view = { rules = { disable = { 'pdf' } }, window = { width = 0.5 } }, + }) + H.same({ buf = 10, win = 20 }, handles) + H.eq(nil, view_err) + H.eq('preview', rendered.result.content) + H.eq('msg1', rendered.att.message.id) + H.eq(0.5, rendered.opts.window.width) + end) + + extractor.extract_to_cache, viewer.view, renderer.render = old_extract, old_view, old_render + if not ok then error(err, 0) end + end, + }, + { + name = 'attach.incoming actions notify and stop on extraction/view failures', + run = function() + local incoming = require('notmuch.attach.incoming') + local extractor = require('notmuch.attach.incoming.extractor') + local viewer = require('notmuch.attach.incoming.viewer') + local old_extract, old_view, old_notify = extractor.extract_to_cache, viewer.view, vim.notify + local notes = {} + vim.notify = function(msg, level) notes[#notes + 1] = { msg = msg, level = level } end + + extractor.extract_to_cache = function() + return nil, 'extract failed' + end + + local ok, err = pcall(function() + local success, open_err = incoming.open_part(part(), 'msg1', { cache_dir = '/cache' }) + H.eq(false, success) + H.contains(open_err, 'extract failed') + H.contains(notes[#notes].msg, 'extract failed') + + extractor.extract_to_cache = function() + return '/cache/file.pdf', nil + end + viewer.view = function() + return nil, 'view failed' + end + local handles, view_err = incoming.view_part(part(), 'msg1', { cache_dir = '/cache' }) + H.eq(nil, handles) + H.contains(view_err, 'view failed') + H.contains(notes[#notes].msg, 'view failed') + end) + + extractor.extract_to_cache, viewer.view, vim.notify = old_extract, old_view, old_notify + if not ok then error(err, 0) end + end, + }, +} diff --git a/tests/specs/attach_incoming_viewer_spec.lua b/tests/specs/attach_incoming_viewer_spec.lua new file mode 100644 index 0000000..ae419c5 --- /dev/null +++ b/tests/specs/attach_incoming_viewer_spec.lua @@ -0,0 +1,280 @@ +local H = dofile('tests/helpers.lua') + +local function attachment(part, path) + return { + path = path or '/tmp/doc.pdf', + part = vim.tbl_extend('force', { + id = 1, + content_type = 'application/pdf', + filename = 'doc.pdf', + ext = 'pdf', + disposition = 'attachment', + size = 123, + }, part or {}), + message = { id = 'msg1' }, + } +end + +local function with_view_mocks(mocks, fn) + local old_executable = vim.fn.executable + local old_system = vim.system + + if mocks.executable then + vim.fn.executable = mocks.executable + end + if mocks.system then + vim.system = mocks.system + end + + local ok, err = pcall(fn) + + vim.fn.executable = old_executable + vim.system = old_system + + if not ok then error(err, 0) end +end + +return { + { + name = 'attach.incoming.viewer.view uses first available successful command', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local captured_cmd, captured_opts + + with_view_mocks({ + executable = function(cmd) + return cmd == 'pdftotext' and 1 or 0 + end, + system = function(cmd, opts) + captured_cmd = cmd + captured_opts = opts + return { wait = function() return { code = 0, stdout = 'pdf text', stderr = '' } end } + end, + }, function() + local result, err = viewer.view(attachment()) + + H.eq(nil, err) + H.eq('pdf text', result.content) + H.eq('text', result.filetype) + H.eq('doc.pdf', result.title) + H.eq('pdf', result.rule) + H.eq('pdftotext', result.source) + H.same({ 'pdftotext', '-layout', '/tmp/doc.pdf', '-' }, captured_cmd) + H.same({ text = true }, captured_opts) + end) + end, + }, + { + name = 'attach.incoming.viewer.view falls back through failed command chains', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local calls = {} + + with_view_mocks({ + executable = function(cmd) + return (cmd == 'pdftotext' or cmd == 'mutool') and 1 or 0 + end, + system = function(cmd) + calls[#calls + 1] = cmd + local tool = cmd[1] + return { + wait = function() + if tool == 'pdftotext' then + return { code = 1, stdout = '', stderr = 'pdf failed' } + end + return { code = 0, stdout = 'mutool text', stderr = '' } + end, + } + end, + }, function() + local result, err = viewer.view(attachment()) + + H.eq(nil, err) + H.eq('mutool text', result.content) + H.eq('mutool', result.source) + H.same({ 'pdftotext', '-layout', '/tmp/doc.pdf', '-' }, calls[1]) + H.same({ 'mutool', 'draw', '-F', 'txt', '/tmp/doc.pdf' }, calls[2]) + end) + end, + }, + { + name = 'attach.incoming.viewer.view returns matched rule fallback when tools are missing', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + + with_view_mocks({ + executable = function() + return 0 + end, + system = function() + error('vim.system should not be called when tools are unavailable') + end, + }, function() + local result, err = viewer.view(attachment()) + + H.eq(nil, err) + H.contains(result.content, 'PDF file') + H.contains(result.content, 'pdftotext') + H.eq('text', result.filetype) + H.eq('doc.pdf', result.title) + H.eq('pdf', result.rule) + H.eq('fallback', result.source) + end) + end, + }, + { + name = 'attach.incoming.viewer.view normalizes handler table results', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local result, err = viewer.view(attachment(), { + rules = { + prepend = { + { + name = 'custom', + match = '*', + handler = function() + return { + content = 'custom content', + filetype = 'markdown', + title = 'Custom Title', + } + end, + }, + }, + }, + }) + + H.eq(nil, err) + H.eq('custom content', result.content) + H.eq('markdown', result.filetype) + H.eq('Custom Title', result.title) + H.eq('custom', result.rule) + H.eq('handler', result.source) + end, + }, + { + name = 'attach.incoming.viewer.view recovers from handler errors with commands', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local captured_cmd + + with_view_mocks({ + executable = function(cmd) + return cmd == 'pdftotext' and 1 or 0 + end, + system = function(cmd) + captured_cmd = cmd + return { wait = function() return { code = 0, stdout = 'command content', stderr = '' } end } + end, + }, function() + local result, err = viewer.view(attachment(), { + rules = { + prepend = { + { + name = 'custom-pdf', + match = { ext = 'pdf' }, + handler = function() + error('boom') + end, + commands = { + { 'pdftotext', '$path', '-' }, + }, + filetype = 'text', + fallback = 'custom fallback', + }, + }, + }, + }) + + H.eq(nil, err) + H.eq('command content', result.content) + H.eq('custom-pdf', result.rule) + H.eq('pdftotext', result.source) + H.same({ 'pdftotext', '/tmp/doc.pdf', '-' }, captured_cmd) + end) + end, + }, + { + name = 'attach.incoming.viewer.view handles text attachments via cat command', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local captured_cmd + + with_view_mocks({ + executable = function(cmd) + return cmd == 'cat' and 1 or 0 + end, + system = function(cmd) + captured_cmd = cmd + return { wait = function() return { code = 0, stdout = 'plain text body', stderr = '' } end } + end, + }, function() + local result, err = viewer.view(attachment({ + content_type = 'text/plain', + filename = 'note.txt', + ext = 'txt', + }, '/tmp/note.txt')) + + H.eq(nil, err) + H.eq('plain text body', result.content) + H.eq('text', result.filetype) + H.eq('note.txt', result.title) + H.eq('text', result.rule) + H.eq('cat', result.source) + H.same({ 'cat', '/tmp/note.txt' }, captured_cmd) + end) + end, + }, + { + name = 'attach.incoming.viewer.view uses binary fallback when strings is unavailable', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + + with_view_mocks({ + executable = function() + return 0 + end, + system = function() + error('vim.system should not be called when strings is unavailable') + end, + }, function() + local result, err = viewer.view(attachment({ + content_type = 'application/octet-stream', + filename = 'blob.bin', + ext = 'bin', + }, '/tmp/blob.bin')) + + H.eq(nil, err) + H.contains(result.content, 'Unable to view binary file') + H.contains(result.content, 'application/octet-stream') + H.contains(result.content, '/tmp/blob.bin') + H.eq('binary', result.rule) + H.eq('fallback', result.source) + end) + end, + }, + { + name = 'attach.incoming.viewer.view reports no matching rule', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local result, err = viewer.view(attachment(), { + rules = { + disable = { 'html', 'pdf', 'image', 'office', 'markdown', 'zip', 'tar', 'text', 'binary' }, + }, + }) + + H.eq(nil, result) + H.contains(err, 'No view rule matched attachment') + end, + }, + { + name = 'attach.incoming.viewer.view validates attachment input', + run = function() + local viewer = require('notmuch.attach.incoming.viewer') + local result, err = viewer.view(nil) + + H.eq(nil, result) + H.contains(err, 'attachment must be a table') + end, + }, +} diff --git a/tests/specs/attach_parts_spec.lua b/tests/specs/attach_parts_spec.lua index 56b439d..97c7eff 100644 --- a/tests/specs/attach_parts_spec.lua +++ b/tests/specs/attach_parts_spec.lua @@ -257,10 +257,10 @@ return { end, }, { - name = "attach.parts.save/open/view handlers handle failed saves and configured callbacks", + name = "attach.parts.open/view delegates selected part to incoming subsystem", run = function() local attach = require("notmuch.attach.parts") - local config = require("notmuch.config") + local incoming = require("notmuch.attach.incoming") local part = { id = 9, content_type = "text/plain", filename = "view.txt", disposition = "attachment", size = 1 } local buf = attachment_buf({ part }, "id:handler-msg") vim.api.nvim_win_set_cursor(0, { 4, 0 }) @@ -269,26 +269,30 @@ return { H.eq(nil, attach.save_attachment_part("/dev/null", false)) end) - local old_open, old_view = config.options.open_handler, config.options.view_handler + local old_open, old_view = incoming.open_part, incoming.view_part local opened, viewed - config.options.open_handler = function(attachment) opened = attachment.path end - config.options.view_handler = function(attachment) viewed = attachment.path; return "viewed output" end + incoming.open_part = function(selected, message_id) + opened = { part = selected, message_id = message_id } + return true + end + incoming.view_part = function(selected, message_id) + viewed = { part = selected, message_id = message_id } + return { buf = 1, win = 1 } + end + + local ok, err = pcall(function() + H.eq(true, attach.open_attachment_part()) + H.same(part, opened.part) + H.eq("id:handler-msg", opened.message_id) - with_system_result(0, function() - silence_print(function() - attach.open_attachment_part() - end) - H.eq("/tmp/view.txt", opened) - silence_print(function() - attach.view_attachment_part() - end) - H.eq("/tmp/view.txt", viewed) - H.contains(vim.api.nvim_buf_get_lines(0, 0, -1, false), "viewed output") - vim.cmd("close") + H.same({ buf = 1, win = 1 }, attach.view_attachment_part()) + H.same(part, viewed.part) + H.eq("id:handler-msg", viewed.message_id) end) - config.options.open_handler, config.options.view_handler = old_open, old_view + incoming.open_part, incoming.view_part = old_open, old_view vim.api.nvim_buf_delete(buf, { force = true }) + if not ok then error(err, 0) end end, }, } diff --git a/tests/specs/config_spec.lua b/tests/specs/config_spec.lua index 89ce2db..6d282a8 100644 --- a/tests/specs/config_spec.lua +++ b/tests/specs/config_spec.lua @@ -29,21 +29,15 @@ return { ["user.name"] = false, ["user.primary_email"] = false, }, function() - local open_handler = function() end - local view_handler = function() end H.eq(true, config.setup({ notmuch_db_path = "~/custom-db", maildir_sync_cmd = "true", - open_handler = open_handler, - view_handler = view_handler, sync = { sync_mode = "background" }, drafts = { auto_open_attachment_window = true }, keymaps = { sendmail = "" }, })) H.eq(vim.fn.expand("~/custom-db"), config.options.notmuch_db_path) H.eq("User ", config.options.from) - H.eq(open_handler, config.options.open_handler) - H.eq(view_handler, config.options.view_handler) H.eq("background", config.options.sync.sync_mode) H.eq(true, config.options.drafts.auto_open_attachment_window) H.eq("", config.options.keymaps.sendmail) @@ -69,6 +63,194 @@ return { end) end, }, + { + name = "config.setup provides incoming attachment config defaults", + run = function() + local config = require("notmuch.config") + with_mocked_notmuch_config({ + ["database.path"] = "/tmp/notmuch-db", + ["user.name"] = "Tester", + ["user.primary_email"] = "tester@example.com", + }, function() + H.eq(true, config.setup({})) + + local incoming = config.options.attach.incoming + H.ok(incoming.cache_dir) + H.contains(incoming.cache_dir, "notmuch.nvim") + H.contains(incoming.cache_dir, "attachments") + + H.same({}, incoming.open.rules.prepend) + H.same({}, incoming.open.rules.append) + H.same({}, incoming.open.rules.replace) + H.same({}, incoming.open.rules.disable) + + H.same({}, incoming.view.rules.prepend) + H.same({}, incoming.view.rules.append) + H.same({}, incoming.view.rules.replace) + H.same({}, incoming.view.rules.disable) + + H.eq("float", incoming.view.window.type) + H.eq(0.8, incoming.view.window.width) + H.eq(0.8, incoming.view.window.height) + H.eq("rounded", incoming.view.window.border) + end) + end, + }, + { + name = "config.setup merges incoming attachment rule patches", + run = function() + local config = require("notmuch.config") + with_mocked_notmuch_config({ + ["database.path"] = "/tmp/notmuch-db", + ["user.name"] = "Tester", + ["user.primary_email"] = "tester@example.com", + }, function() + H.eq(true, config.setup({ + attach = { + incoming = { + open = { + rules = { + prepend = { + { + name = "pdf-zathura", + match = { ext = "pdf" }, + command = { "zathura", "$path" }, + detach = true, + }, + }, + }, + }, + view = { + rules = { + disable = { "pdf" }, + replace = { + html = { + name = "html", + match = { content_type = "text/html" }, + commands = { + { "custom-html", "$path" }, + }, + }, + }, + }, + }, + }, + }, + })) + + local incoming = config.options.attach.incoming + local open_rule = incoming.open.rules.prepend[1] + H.eq("pdf-zathura", open_rule.name) + H.same({ "zathura", "$path" }, open_rule.command) + H.eq(true, open_rule.detach) + + H.same({ "pdf" }, incoming.view.rules.disable) + H.eq("html", incoming.view.rules.replace.html.name) + H.same({ "custom-html", "$path" }, incoming.view.rules.replace.html.commands[1]) + H.same({}, incoming.view.rules.prepend) + H.same({}, incoming.view.rules.append) + end) + end, + }, + { + name = "config.setup maps attachment shorthand to incoming rule config", + run = function() + local config = require("notmuch.config") + with_mocked_notmuch_config({ + ["database.path"] = "/tmp/notmuch-db", + ["user.name"] = "Tester", + ["user.primary_email"] = "tester@example.com", + }, function() + H.eq(true, config.setup({ + attachments = { + cache_dir = "~/notmuch-shorthand-cache", + open = { + { + name = "pdf-firefox", + match = { ext = "pdf" }, + command = { "firefox", "$path" }, + detach = true, + fallback = "Could not open PDF with Firefox", + }, + }, + view = { + { + name = "pdf-text", + match = { content_type = "application/pdf" }, + commands = { + { "pdftotext", "-raw", "$path", "-" }, + }, + filetype = "text", + fallback = "Install pdftotext to preview PDFs.", + }, + }, + window = { + width = 0.9, + height = 0.7, + border = "single", + }, + }, + })) + + local incoming = config.options.attach.incoming + H.eq(vim.fn.expand("~/notmuch-shorthand-cache"), incoming.cache_dir) + + local open_rule = incoming.open.rules.prepend[1] + H.eq("pdf-firefox", open_rule.name) + H.same({ ext = "pdf" }, open_rule.match) + H.same({ "firefox", "$path" }, open_rule.command) + H.eq(true, open_rule.detach) + H.eq("Could not open PDF with Firefox", open_rule.fallback) + + local view_rule = incoming.view.rules.prepend[1] + H.eq("pdf-text", view_rule.name) + H.same({ content_type = "application/pdf" }, view_rule.match) + H.same({ "pdftotext", "-raw", "$path", "-" }, view_rule.commands[1]) + H.eq("text", view_rule.filetype) + H.eq("Install pdftotext to preview PDFs.", view_rule.fallback) + + H.eq(0.9, incoming.view.window.width) + H.eq(0.7, incoming.view.window.height) + H.eq("single", incoming.view.window.border) + end) + end, + }, + { + name = "config.setup does not expose legacy incoming attachment handlers by default", + run = function() + local config = require("notmuch.config") + with_mocked_notmuch_config({ + ["database.path"] = "/tmp/notmuch-db", + ["user.name"] = "Tester", + ["user.primary_email"] = "tester@example.com", + }, function() + H.eq(true, config.setup({})) + H.eq(nil, config.options.open_handler) + H.eq(nil, config.options.view_handler) + end) + end, + }, + { + name = "config.setup expands incoming attachment cache directory", + run = function() + local config = require("notmuch.config") + with_mocked_notmuch_config({ + ["database.path"] = "/tmp/notmuch-db", + ["user.name"] = "Tester", + ["user.primary_email"] = "tester@example.com", + }, function() + H.eq(true, config.setup({ + attach = { + incoming = { + cache_dir = "~/notmuch-test-cache", + }, + }, + })) + + H.eq(vim.fn.expand("~/notmuch-test-cache"), config.options.attach.incoming.cache_dir) + end) + end, + }, { name = "config.setup fails gracefully when database.path is missing", run = function() diff --git a/tests/specs/handlers_spec.lua b/tests/specs/handlers_spec.lua deleted file mode 100644 index 633637c..0000000 --- a/tests/specs/handlers_spec.lua +++ /dev/null @@ -1,199 +0,0 @@ -local H = dofile("tests/helpers.lua") - -local function with_view_mocks(mime_type, ext_path, available, results, fn) - local old_executable = vim.fn.executable - local old_system_fn = vim.fn.system - local old_system = vim.system - local calls = {} - - vim.fn.executable = function(tool) - return available[tool] and 1 or 0 - end - vim.fn.system = function(cmd) - calls[#calls + 1] = cmd - if type(cmd) == "table" and cmd[1] == "file" then - return mime_type .. "\n" - end - if type(cmd) == "table" then - local key = table.concat(cmd, " ") - return results[key] and results[key].stdout or "fn-system output" - end - return "fn-system output" - end - vim.system = function(cmd) - calls[#calls + 1] = cmd - local key = table.concat(cmd, " ") - local result = results[key] or { code = 0, stdout = cmd[1] .. " output" } - return { wait = function() return result end } - end - - local ok, err = pcall(fn, calls, ext_path) - vim.fn.executable = old_executable - vim.fn.system = old_system_fn - vim.system = old_system - if not ok then error(err, 0) end -end - -return { - { - name = "handlers.default_open_handler uses vim.ui.open when available", - run = function() - local handlers = require("notmuch.handlers") - local old_open = vim.ui.open - local opened - - vim.ui.open = function(path) - opened = path - return { wait = function() return { code = 0 } end }, nil - end - - handlers.default_open_handler({ path = "/tmp/file.txt" }) - - H.eq("/tmp/file.txt", opened) - vim.ui.open = old_open - end, - }, - { - name = "handlers.default_open_handler reports vim.ui.open failures", - run = function() - local handlers = require("notmuch.handlers") - local old_open = vim.ui.open - local old_notify = vim.notify - local note - - vim.ui.open = function() - return nil, "no opener found" - end - vim.notify = function(msg, level) - note = { msg = msg, level = level } - end - - handlers.default_open_handler({ path = "/tmp/file.txt" }) - - H.contains(note.msg, "failed to open attachment") - H.contains(note.msg, "no opener found") - H.eq(vim.log.levels.ERROR, note.level) - - vim.ui.open = old_open - vim.notify = old_notify - end, - }, - { - name = "handlers.default_open_handler falls back to OS opener without vim.ui.open", - run = function() - local handlers = require("notmuch.handlers") - local old_open = vim.ui.open - local old_system = vim.system - local captured_cmd, captured_opts - - vim.ui.open = nil - vim.system = function(cmd, opts) - captured_cmd = cmd - captured_opts = opts - return { wait = function() return { code = 0, stdout = "" } end } - end - - handlers.default_open_handler({ path = "/tmp/file.txt" }) - - local sysname = vim.uv.os_uname().sysname - local expected = (sysname == "Darwin" and "open") - or (sysname == "Linux" and "xdg-open") - or (sysname:match("Windows") and "start") - or "xdg-open" - - H.same({ expected, "/tmp/file.txt" }, captured_cmd) - H.same({ detach = true }, captured_opts) - - vim.ui.open = old_open - vim.system = old_system - end, - }, - { - name = "handlers.default_view_handler reads plain text attachments", - run = function() - local handlers = require("notmuch.handlers") - local dir = H.tmpdir() - local file = H.write_file(dir .. "/note.txt", "hello viewer\n") - - local output = handlers.default_view_handler({ path = file }) - H.contains(output, "hello viewer") - end, - }, - { - name = "handlers.default_view_handler uses preferred viewers by MIME type", - run = function() - local handlers = require("notmuch.handlers") - local cases = { - { mime = "text/html", path = "/tmp/page.html", tools = { "w3m", "lynx", "elinks" }, expected = { "w3m", "-T", "text/html", "-dump", "/tmp/page.html" } }, - { mime = "application/pdf", path = "/tmp/doc.pdf", tools = { "pdftotext", "mutool" }, expected = { "pdftotext", "-layout", "/tmp/doc.pdf", "-" } }, - { mime = "image/png", path = "/tmp/img.png", tools = { "chafa", "catimg", "viu", "exiftool", "identify" }, expected = { "chafa", "--size", "80x40", "/tmp/img.png" } }, - { mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", path = "/tmp/doc.docx", tools = { "pandoc", "docx2txt" }, expected = { "pandoc", "-t", "plain", "/tmp/doc.docx" } }, - { mime = "text/markdown", path = "/tmp/readme.md", tools = { "pandoc", "mdcat" }, expected = { "pandoc", "-t", "plain", "/tmp/readme.md" } }, - } - - for _, case in ipairs(cases) do - local available = {} - for _, tool in ipairs(case.tools) do available[tool] = true end - with_view_mocks(case.mime, case.path, available, {}, function(calls) - local output = handlers.default_view_handler({ path = case.path }) - H.contains(output, case.expected[1] .. " output") - H.same(case.expected, calls[2]) - end) - end - end, - }, - { - name = "handlers.default_view_handler falls back through viewer chains", - run = function() - local handlers = require("notmuch.handlers") - with_view_mocks("text/html", "/tmp/page.html", { w3m = true, lynx = true }, { - ["w3m -T text/html -dump /tmp/page.html"] = { code = 1, stdout = "" }, - ["lynx -dump -nolist /tmp/page.html"] = { code = 0, stdout = "lynx rendered" }, - }, function(calls) - H.eq("lynx rendered", handlers.default_view_handler({ path = "/tmp/page.html" })) - H.same({ "w3m", "-T", "text/html", "-dump", "/tmp/page.html" }, calls[2]) - H.same({ "lynx", "-dump", "-nolist", "/tmp/page.html" }, calls[3]) - end) - end, - }, - { - name = "handlers.default_view_handler handles archives, markdown cat fallback, binary strings, and no-viewer fallback", - run = function() - local handlers = require("notmuch.handlers") - - with_view_mocks("application/zip", "/tmp/archive.zip", {}, { - ["unzip -l /tmp/archive.zip"] = { code = 0, stdout = "zip listing" }, - }, function(calls) - H.eq("zip listing", handlers.default_view_handler({ path = "/tmp/archive.zip" })) - H.same({ "unzip", "-l", "/tmp/archive.zip" }, calls[2]) - end) - - with_view_mocks("application/x-tar", "/tmp/archive.tar", {}, { - ["tar -tvf /tmp/archive.tar"] = { code = 0, stdout = "tar listing" }, - }, function(calls) - H.eq("tar listing", handlers.default_view_handler({ path = "/tmp/archive.tar" })) - H.same({ "tar", "-tvf", "/tmp/archive.tar" }, calls[2]) - end) - - with_view_mocks("text/markdown", "/tmp/readme.md", {}, { - ["cat /tmp/readme.md"] = { code = 0, stdout = "raw markdown" }, - }, function(calls) - H.eq("raw markdown", handlers.default_view_handler({ path = "/tmp/readme.md" })) - H.same({ "cat", "/tmp/readme.md" }, calls[2]) - end) - - with_view_mocks("application/octet-stream", "/tmp/blob.bin", { strings = true }, { - ["strings /tmp/blob.bin"] = { code = 0, stdout = "binary text" }, - }, function(calls) - H.eq("binary text", handlers.default_view_handler({ path = "/tmp/blob.bin" })) - H.same({ "strings", "/tmp/blob.bin" }, calls[2]) - end) - - with_view_mocks("application/octet-stream", "/tmp/blob.bin", {}, {}, function() - local output = handlers.default_view_handler({ path = "/tmp/blob.bin" }) - H.contains(output, "Unable to view binary file") - H.contains(output, "application/octet-stream") - end) - end, - }, -} diff --git a/tests/specs/ui_spec.lua b/tests/specs/ui_spec.lua index f584ea0..9670cb0 100644 --- a/tests/specs/ui_spec.lua +++ b/tests/specs/ui_spec.lua @@ -160,13 +160,33 @@ return { local nm = require("notmuch") local attach = require("notmuch.attach.parts") local config = require("notmuch.config") - local old_open, old_view = config.options.open_handler, config.options.view_handler + local old_incoming = vim.deepcopy(config.options.attach.incoming) local opened, viewed - config.options.open_handler = function(attachment) opened = attachment.path end - config.options.view_handler = function(attachment) - viewed = attachment.path - return "stub viewed attachment" - end + config.options.attach.incoming.cache_dir = H.tmpdir() .. "/incoming-cache" + config.options.attach.incoming.open.rules = { + replace = { + system = { + name = "system", + match = "*", + handler = function(attachment) + opened = attachment.path + return true, nil + end, + }, + }, + } + config.options.attach.incoming.view.rules = { + prepend = { + { + name = "stub-view", + match = "*", + handler = function(attachment) + viewed = attachment.path + return { content = "stub viewed attachment", filetype = "text", title = "stub" } + end, + }, + }, + } local ok, err = pcall(function() vim.cmd("NmSearch tag:attachment") @@ -197,16 +217,16 @@ return { vim.api.nvim_win_set_cursor(0, { 4, 0 }) attach.open_attachment_part() - H.ok(opened and opened:find("/tmp/", 1, true), "expected open handler to receive /tmp path") + H.ok(opened and opened:find("incoming%-cache", 1, false), "expected opener to receive cached path") vim.api.nvim_win_set_cursor(0, { 4, 0 }) attach.view_attachment_part() - H.ok(viewed and viewed:find("/tmp/", 1, true), "expected view handler to receive /tmp path") + H.ok(viewed and viewed:find("incoming%-cache", 1, false), "expected viewer to receive cached path") H.contains(H.current_lines(), "stub viewed attachment") vim.cmd("close") end) - config.options.open_handler, config.options.view_handler = old_open, old_view + config.options.attach.incoming = old_incoming if not ok then error(err, 0) end end, },