May 14, 2026
1 pull request merged across 1 repo
bahdotsh/feedr
Adds three newsboat-style extensibility primitives so users can self-serve the long tail of "save to X / notify on Y / pipe to Z" workflows without feedr chasing one-off integrations:
pipe-to— pipes the focused article (body / title / url / metadata, configurable per macro step) to a shell command's stdin. The TUI is suspended for the duration so pagers and editors take over the terminal cleanly.exec_on_new— fires a per-item command after each refresh. The first successful fetch of a feed seeds the seen set silently, so enabling this on a 200-item feed doesn't notify-spam.- Macros — bind a chord (default prefix
,then key) to an ordered chain of actions / pipe-to / exec steps. Newsboat-compatible string syntax (open-in-browser ; pipe-to "yt-dlp %u"); existing newsboat macros mostly paste in directly.
Design notes
Safety stance. Templates are tokenized once via shlex::split and %X placeholders are substituted into individual argv slots. Commands are not run through sh -c. That means pipe-to "tee out.txt | wc -l" won't pipeline — wrap it in a script. The alternative is letting a malicious feed title execute arbitrary commands, which is not a trade I was willing to make. Documented in the auto-generated config comments.
Sequential macro semantics. All three step kinds (Action, PipeTo, Exec) are queued onto app.pending_macro_steps (FIFO) and drained in order by the TUI loop. Earlier draft ran Action steps inline and queued only the externals — that broke pipe-to "X" ; toggle-read ordering. Refactored before commit.
Terminal suspend. tui::suspend_for_command does the verified ratatui dance: LeaveAlternateScreen → DisableMouseCapture → disable_raw_mode → spawn → wait → enable_raw_mode → EnterAlternateScreen → EnableMouseCapture → terminal.clear(), with an RAII guard so a panicking child can't strand the user in raw mode. Pending input drained on return to absorb terminal-probe responses (less/vim background-color queries).
exec_on_new first-fetch suppression. A new persisted feeds_seeded: HashSet<String> records URLs that have been fetched at least once. The very first successful fetch silently seeds seen_items without firing hooks. Persists across restarts via feedr_data.json (additive serde(default) field, fully back-compat with v0.7.0 data files).
Config schema (all serde(default))
[hooks]
exec_on_new = 'notify-send "New: %t" "%f"'
[macros]
y = 'open-in-browser ; pipe-to "yt-dlp %u"'
w = 'pipe-to "wallabag-cli add %u" -- "Save to Wallabag"'
n = 'pipe-to "tee out.txt" stdin=metadata'
[macro_options]
prefix = ","
pipe_default_stdin = "body" # body | title | url | metadata | none
Template variables: %t title, %u url, %a author, %d date, %f feed-title, %F feed-url, %% literal %.
Files touched
src/keybindings.rs—MacroStep,MacroBinding,StdinKind,MacroOptions,parse_macro_string,build_macros,binding_displayhelpersrc/config.rs—[hooks],[macros],[macro_options]sectionssrc/app.rs—seen_items/feeds_seededpersistence,ArticleContext,current_article_context,mark_feed_seen,expand_argv_template,make_pipe_payloadsrc/events.rs— macro-prefix detection inhandle_key_event,run_macroqueueing,dispatch_actionsrc/tui.rs—suspend_for_command,spawn_detached,drain_macro_steps,fire_exec_on_newwith first-fetch suppressionsrc/ui/modals.rs— Macros section in the help overlayCargo.toml—shlex = "1.3"(for argv tokenization)
Test plan
-
cargo test --lib— 103 tests passing (12 new for macro parser, 8 new for app helpers, 2 new for config back-compat / round-trip) -
cargo test --tests— 4 integration tests passing -
cargo clippy --all-targets --all-features -- -D warnings— clean -
cargo fmt --all -- --check— clean -
cargo build --release— clean - Manual smoke test: with
[macros] x = 'pipe-to "cat"', press,xon an article — body appears in stdout, terminal restores cleanly on Ctrl+D. - Manual smoke test: with
[hooks] exec_on_new = 'tee -a /tmp/feedr.log', refresh — log file is silent on first fetch of a feed, then gets one line per genuinely new item on subsequent refreshes. - Manual smoke test:
[macros] y = 'open-in-browser ; pipe-to "yt-dlp %u"'on a YouTube link — opens browser, then pipes URL to yt-dlp.
Notes for reviewers
- This is item #3 of three planned newsboat-parity features; sync (#1) and Readability extraction (#2) are tracked separately.
- The diverge-from-newsboat call on
sh -cis the most consequential design decision here. Open to revisiting if there's pushback, but a per-step opt-inshell = truewould be the right shape rather than a global flag.