feat(core): Add action-fd headless scripting protocol.

This commit is contained in:
2026-03-13 21:57:30 -04:00
parent c74e4ea9fb
commit cb7911e5c9
4 changed files with 818 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
//! Error types for action-fd script parsing and validation.
//! Errors carry line numbers and source text for rustc-style
//! diagnostic output.
use std::fmt;
/// Parse or validation error from an action-fd script.
/// Carries enough context to produce rustc-style
/// diagnostics with line numbers and carets.
#[derive(Debug)]
pub struct ScriptError {
pub line: usize,
pub source_line: String,
pub kind: ScriptErrorKind,
}
#[derive(Debug)]
pub enum ScriptErrorKind {
UnknownAction(String),
InvalidArgument { action: String, message: String },
ActionsAfterShowUi,
}
/// Write the common diagnostic header: error message, gutter,
/// and the source line. Returns the indent string for use by
/// the caller's annotation lines.
fn write_diagnostic_header(
f: &mut fmt::Formatter<'_>,
error_msg: &str,
line: usize,
source: &str,
) -> Result<String, fmt::Error> {
let indent = " ".repeat(line.to_string().len());
write!(
f,
"error: {error_msg} on action-fd line {line}\n\
{indent}|\n\
{line} | {source}\n",
)?;
Ok(indent)
}
impl fmt::Display for ScriptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
ScriptErrorKind::UnknownAction(action) => {
let indent =
write_diagnostic_header(f, "unknown action", self.line, &self.source_line)?;
let underline = "^".repeat(action.len().max(1));
write!(
f,
"{indent}| {underline} not a valid action\n\
{indent}|\n\
{indent}= help: valid actions: filter, move-up, move-down, move-to-top, \
move-to-bottom, page-up, page-down, resize, confirm, cancel, \
show-ui, show-tui, show-gui",
)
}
ScriptErrorKind::InvalidArgument { action, message } => {
let indent =
write_diagnostic_header(f, "invalid argument", self.line, &self.source_line)?;
let underline = "^".repeat(self.source_line.len().max(1));
write!(
f,
"{indent}| {underline} {message}\n\
{indent}|\n\
{indent}= help: usage: {action} <value>",
)
}
ScriptErrorKind::ActionsAfterShowUi => {
let indent = write_diagnostic_header(
f,
"actions after show-ui",
self.line,
&self.source_line,
)?;
write!(
f,
"{indent}| show-ui/show-tui/show-gui must be the last action in the script\n\
{indent}|",
)
}
}
}
}
impl std::error::Error for ScriptError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_unknown_action() {
let err = ScriptError {
line: 3,
source_line: "bogus".to_string(),
kind: ScriptErrorKind::UnknownAction("bogus".to_string()),
};
let display = err.to_string();
assert!(display.contains("error: unknown action on action-fd line 3"));
assert!(display.contains("bogus"));
assert!(display.contains("not a valid action"));
assert!(display.contains("help: valid actions:"));
}
#[test]
fn display_invalid_argument() {
let err = ScriptError {
line: 5,
source_line: "resize abc".to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: "resize".to_string(),
message: "'abc' is not a valid positive integer".to_string(),
},
};
let display = err.to_string();
assert!(display.contains("error: invalid argument on action-fd line 5"));
assert!(display.contains("resize abc"));
}
#[test]
fn display_gutter_width_multi_digit_line() {
let err = ScriptError {
line: 100,
source_line: "bogus".to_string(),
kind: ScriptErrorKind::UnknownAction("bogus".to_string()),
};
let display = err.to_string();
// Line 100 = 3 digits, so indent should be " " (3 spaces)
assert!(display.contains(" |"));
assert!(display.contains("100 | bogus"));
}
#[test]
fn display_actions_after_show() {
let err = ScriptError {
line: 4,
source_line: "confirm".to_string(),
kind: ScriptErrorKind::ActionsAfterShowUi,
};
let display = err.to_string();
assert!(display.contains("actions after show-ui on action-fd line 4"));
assert!(display.contains("must be the last action"));
}
}

View File

@@ -0,0 +1,107 @@
//! Action-fd scripting: drive the menu from a script piped
//! in via `--action-fd`.
//!
//! Scripts are line-oriented text, one action per line.
//! The script is parsed and validated upfront (fail-fast),
//! then replayed into the menu's action channel. A script
//! can optionally end with `show-ui`/`show-tui`/`show-gui`
//! to hand off to an interactive frontend after the scripted
//! actions complete.
pub mod error;
pub mod parse;
use tokio::sync::mpsc;
use crate::error::PiklError;
use crate::event::Action;
pub use error::{ScriptError, ScriptErrorKind};
pub use parse::{load_script, parse_action};
/// A parsed action from an action-fd script.
#[derive(Debug, Clone, PartialEq)]
pub enum ScriptAction {
Core(Action),
ShowUi,
ShowTui,
ShowGui,
Comment,
}
/// Which frontend to hand off to after running a script.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ShowAction {
Ui,
Tui,
Gui,
}
/// Send all Core actions from the script into the channel.
/// Returns the Show* variant if the script ends with one, or None.
pub async fn run_script(
script: Vec<ScriptAction>,
tx: &mpsc::Sender<Action>,
) -> Result<Option<ShowAction>, PiklError> {
let mut show = None;
for action in script {
match action {
ScriptAction::Core(action) => {
tx.send(action)
.await
.map_err(|_| PiklError::ChannelClosed)?;
}
ScriptAction::ShowUi => show = Some(ShowAction::Ui),
ScriptAction::ShowTui => show = Some(ShowAction::Tui),
ScriptAction::ShowGui => show = Some(ShowAction::Gui),
ScriptAction::Comment => {}
}
}
Ok(show)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn run_script_sends_actions_in_order() {
let script = vec![
ScriptAction::Core(Action::UpdateFilter("hello".to_string())),
ScriptAction::Core(Action::MoveDown(1)),
ScriptAction::Core(Action::Confirm),
];
let (tx, mut rx) = mpsc::channel(16);
let result = run_script(script, &tx).await;
assert!(result.is_ok());
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
// Verify order
assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello"));
assert!(matches!(rx.recv().await, Some(Action::MoveDown(1))));
assert!(matches!(rx.recv().await, Some(Action::Confirm)));
}
#[tokio::test]
async fn run_script_returns_none_without_show() {
let script = vec![ScriptAction::Core(Action::Confirm)];
let (tx, _rx) = mpsc::channel(16);
let result = run_script(script, &tx).await;
assert!(result.is_ok());
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
}
#[tokio::test]
async fn run_script_returns_show_action() {
let script = vec![
ScriptAction::Core(Action::UpdateFilter("test".to_string())),
ScriptAction::ShowUi,
];
let (tx, _rx) = mpsc::channel(16);
let result = run_script(script, &tx).await;
assert!(result.is_ok());
assert_eq!(result.unwrap_or(None), Some(ShowAction::Ui));
}
}

View File

@@ -0,0 +1,564 @@
//! Line-oriented parser for action-fd scripts. Handles
//! single-line parsing, count arguments, and full script
//! loading with validation.
use std::io::BufRead;
use crate::event::Action;
use super::ScriptAction;
use super::error::{ScriptError, ScriptErrorKind};
/// Build an InvalidArgument error for the given action and message.
fn invalid_arg(line_number: usize, line: &str, action: &str, message: String) -> ScriptError {
ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: action.to_string(),
message,
},
}
}
/// Parse an optional positive integer count from a command argument.
/// Returns 1 if no argument is given.
fn parse_count(
line_number: usize,
line: &str,
action: &str,
arg: Option<&str>,
) -> Result<usize, ScriptError> {
match arg {
None => Ok(1),
Some(s) => match s.trim().parse::<usize>() {
Ok(0) => Err(invalid_arg(
line_number,
line,
action,
"count must be a positive number".to_string(),
)),
Ok(n) => Ok(n),
Err(_) => Err(invalid_arg(
line_number,
line,
action,
format!("'{}' is not a valid positive integer", s.trim()),
)),
},
}
}
/// Parse a required positive u16 from a command argument.
/// Used for values like resize height.
fn parse_positive_u16(
line_number: usize,
line: &str,
action: &str,
arg: Option<&str>,
) -> Result<u16, ScriptError> {
let Some(s) = arg else {
return Err(invalid_arg(
line_number,
line,
action,
format!("missing {action} value"),
));
};
match s.trim().parse::<u16>() {
Ok(0) => Err(invalid_arg(
line_number,
line,
action,
format!("{action} must be a positive number"),
)),
Ok(n) => Ok(n),
Err(_) => Err(invalid_arg(
line_number,
line,
action,
format!("'{}' is not a valid positive integer", s.trim()),
)),
}
}
/// Parse a single line into a ScriptAction.
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return Ok(ScriptAction::Comment);
}
let (cmd, arg) = match trimmed.split_once(' ') {
Some((c, a)) => (c, Some(a)),
None => (trimmed, None),
};
match cmd {
"filter" => {
let text = arg.unwrap_or("");
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string())))
}
"move-up" => {
let n = parse_count(line_number, line, "move-up", arg)?;
Ok(ScriptAction::Core(Action::MoveUp(n)))
}
"move-down" => {
let n = parse_count(line_number, line, "move-down", arg)?;
Ok(ScriptAction::Core(Action::MoveDown(n)))
}
"move-to-top" => Ok(ScriptAction::Core(Action::MoveToTop)),
"move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)),
"page-up" => {
let n = parse_count(line_number, line, "page-up", arg)?;
Ok(ScriptAction::Core(Action::PageUp(n)))
}
"page-down" => {
let n = parse_count(line_number, line, "page-down", arg)?;
Ok(ScriptAction::Core(Action::PageDown(n)))
}
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
"resize" => {
let height = parse_positive_u16(line_number, line, "resize", arg)?;
Ok(ScriptAction::Core(Action::Resize { height }))
}
"show-ui" => Ok(ScriptAction::ShowUi),
"show-tui" => Ok(ScriptAction::ShowTui),
"show-gui" => Ok(ScriptAction::ShowGui),
_ => Err(ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::UnknownAction(cmd.to_string()),
}),
}
}
// Intermediate struct to track line numbers during script loading.
struct ParsedLine {
line_number: usize,
source: String,
action: ScriptAction,
}
/// Validate that show-* actions (if present) are the last
/// action in the script. Returns an error pointing at the
/// first action after a show-* command.
fn validate_show_last(actions: &[ParsedLine]) -> Result<(), ScriptError> {
if let Some(show_pos) = actions.iter().position(|p| {
matches!(
p.action,
ScriptAction::ShowUi | ScriptAction::ShowTui | ScriptAction::ShowGui
)
}) {
if show_pos < actions.len() - 1 {
let offender = &actions[show_pos + 1];
return Err(ScriptError {
line: offender.line_number,
source_line: offender.source.clone(),
kind: ScriptErrorKind::ActionsAfterShowUi,
});
}
}
Ok(())
}
/// Load and validate an entire script from a reader.
/// Parses all lines, then validates (e.g. nothing after show-ui).
/// Returns only the actionable entries (Comments filtered out).
pub fn load_script(reader: impl BufRead) -> Result<Vec<ScriptAction>, ScriptError> {
let mut parsed = Vec::new();
for (i, line) in reader.lines().enumerate() {
let line = line.map_err(|e| ScriptError {
line: i + 1,
source_line: String::new(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: e.to_string(),
},
})?;
let action = parse_action(i + 1, &line)?;
parsed.push(ParsedLine {
line_number: i + 1,
source: line,
action,
});
}
// Filter out comments
let actionable: Vec<ParsedLine> = parsed
.into_iter()
.filter(|p| !matches!(p.action, ScriptAction::Comment))
.collect();
validate_show_last(&actionable)?;
Ok(actionable.into_iter().map(|p| p.action).collect())
}
#[cfg(test)]
mod tests {
use super::*;
// -- parse_action tests --
#[test]
fn parse_filter_with_text() {
let result = parse_action(1, "filter hello world");
assert!(result.is_ok());
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UpdateFilter("hello world".to_string()))
);
}
#[test]
fn parse_filter_no_arg_clears() {
let result = parse_action(1, "filter");
assert!(result.is_ok());
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UpdateFilter(String::new()))
);
}
#[test]
fn parse_movement_actions() {
assert_eq!(
parse_action(1, "move-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(1))
);
assert_eq!(
parse_action(1, "move-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(1))
);
assert_eq!(
parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToTop)
);
assert_eq!(
parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToBottom)
);
assert_eq!(
parse_action(1, "page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(1))
);
assert_eq!(
parse_action(1, "page-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageDown(1))
);
}
#[test]
fn parse_movement_with_count() {
assert_eq!(
parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(5))
);
assert_eq!(
parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(3))
);
assert_eq!(
parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(2))
);
assert_eq!(
parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageDown(10))
);
}
#[test]
fn parse_movement_count_zero_is_error() {
assert!(parse_action(1, "move-up 0").is_err());
assert!(parse_action(1, "move-down 0").is_err());
assert!(parse_action(1, "page-up 0").is_err());
assert!(parse_action(1, "page-down 0").is_err());
}
#[test]
fn parse_movement_count_invalid_is_error() {
assert!(parse_action(1, "move-up abc").is_err());
assert!(parse_action(1, "page-down -1").is_err());
}
#[test]
fn parse_confirm_cancel() {
assert_eq!(
parse_action(1, "confirm").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm)
);
assert_eq!(
parse_action(1, "cancel").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Cancel)
);
}
#[test]
fn parse_resize_valid() {
assert_eq!(
parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Resize { height: 25 })
);
}
#[test]
fn parse_resize_no_arg() {
let result = parse_action(3, "resize");
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.line, 3);
assert!(matches!(e.kind, ScriptErrorKind::InvalidArgument { .. }));
}
}
#[test]
fn parse_resize_not_a_number() {
let result = parse_action(2, "resize abc");
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.line, 2);
assert!(matches!(e.kind, ScriptErrorKind::InvalidArgument { .. }));
}
}
#[test]
fn parse_resize_zero() {
let result = parse_action(1, "resize 0");
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e.kind, ScriptErrorKind::InvalidArgument { .. }));
}
}
#[test]
fn parse_show_actions() {
assert_eq!(
parse_action(1, "show-ui").unwrap_or(ScriptAction::Comment),
ScriptAction::ShowUi
);
assert_eq!(
parse_action(1, "show-tui").unwrap_or(ScriptAction::Comment),
ScriptAction::ShowTui
);
assert_eq!(
parse_action(1, "show-gui").unwrap_or(ScriptAction::Comment),
ScriptAction::ShowGui
);
}
#[test]
fn parse_comment_and_blank() {
assert_eq!(
parse_action(1, "").unwrap_or(ScriptAction::ShowUi),
ScriptAction::Comment
);
assert_eq!(
parse_action(1, "# this is a comment").unwrap_or(ScriptAction::ShowUi),
ScriptAction::Comment
);
assert_eq!(
parse_action(1, " ").unwrap_or(ScriptAction::ShowUi),
ScriptAction::Comment
);
}
#[test]
fn parse_unknown_action() {
let result = parse_action(3, "bogus");
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.line, 3);
assert_eq!(e.source_line, "bogus");
assert!(matches!(e.kind, ScriptErrorKind::UnknownAction(ref s) if s == "bogus"));
}
}
// -- parse_positive_u16 tests --
#[test]
fn parse_positive_u16_valid() {
assert_eq!(
parse_positive_u16(1, "resize 25", "resize", Some("25")).unwrap(),
25
);
}
#[test]
fn parse_positive_u16_zero_is_error() {
assert!(parse_positive_u16(1, "resize 0", "resize", Some("0")).is_err());
}
#[test]
fn parse_positive_u16_negative_is_error() {
assert!(parse_positive_u16(1, "resize -1", "resize", Some("-1")).is_err());
}
#[test]
fn parse_positive_u16_non_numeric_is_error() {
assert!(parse_positive_u16(1, "resize abc", "resize", Some("abc")).is_err());
}
#[test]
fn parse_positive_u16_missing_is_error() {
assert!(parse_positive_u16(1, "resize", "resize", None).is_err());
}
#[test]
fn parse_positive_u16_max() {
assert_eq!(
parse_positive_u16(1, "resize 65535", "resize", Some("65535")).unwrap(),
u16::MAX
);
}
#[test]
fn parse_positive_u16_overflow_is_error() {
assert!(parse_positive_u16(1, "resize 65536", "resize", Some("65536")).is_err());
}
// -- validate_show_last tests --
#[test]
fn validate_show_last_empty() {
assert!(validate_show_last(&[]).is_ok());
}
#[test]
fn validate_show_last_at_end() {
let actions = vec![
ParsedLine {
line_number: 1,
source: "filter test".into(),
action: ScriptAction::Core(Action::UpdateFilter("test".into())),
},
ParsedLine {
line_number: 2,
source: "show-ui".into(),
action: ScriptAction::ShowUi,
},
];
assert!(validate_show_last(&actions).is_ok());
}
#[test]
fn validate_show_last_at_start_with_more() {
let actions = vec![
ParsedLine {
line_number: 1,
source: "show-ui".into(),
action: ScriptAction::ShowUi,
},
ParsedLine {
line_number: 2,
source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm),
},
];
let err = validate_show_last(&actions).unwrap_err();
assert!(matches!(err.kind, ScriptErrorKind::ActionsAfterShowUi));
assert_eq!(err.line, 2);
}
#[test]
fn validate_show_last_in_middle() {
let actions = vec![
ParsedLine {
line_number: 1,
source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())),
},
ParsedLine {
line_number: 2,
source: "show-tui".into(),
action: ScriptAction::ShowTui,
},
ParsedLine {
line_number: 3,
source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm),
},
];
let err = validate_show_last(&actions).unwrap_err();
assert!(matches!(err.kind, ScriptErrorKind::ActionsAfterShowUi));
assert_eq!(err.line, 3);
}
#[test]
fn validate_show_last_only_show() {
let actions = vec![ParsedLine {
line_number: 1,
source: "show-gui".into(),
action: ScriptAction::ShowGui,
}];
assert!(validate_show_last(&actions).is_ok());
}
#[test]
fn validate_show_last_no_show() {
let actions = vec![
ParsedLine {
line_number: 1,
source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())),
},
ParsedLine {
line_number: 2,
source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm),
},
];
assert!(validate_show_last(&actions).is_ok());
}
// -- load_script tests --
#[test]
fn load_script_with_show_ui_last() {
let input = "filter hello\nmove-down\nshow-ui\n";
let result = load_script(input.as_bytes());
assert!(result.is_ok());
let actions = result.unwrap_or_default();
assert_eq!(actions.len(), 3);
assert_eq!(actions[2], ScriptAction::ShowUi);
}
#[test]
fn load_script_actions_after_show_ui() {
let input = "show-ui\nconfirm\n";
let result = load_script(input.as_bytes());
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e.kind, ScriptErrorKind::ActionsAfterShowUi));
assert_eq!(e.line, 2);
}
}
#[test]
fn load_script_only_comments() {
let input = "# comment\n\n# another\n";
let result = load_script(input.as_bytes());
assert!(result.is_ok());
assert!(result.unwrap_or(vec![ScriptAction::Comment]).is_empty());
}
#[test]
fn load_script_comments_after_show_ui_ok() {
// Comments are filtered before validation, so trailing comments are fine
let input = "filter test\nshow-ui\n# trailing comment\n";
let result = load_script(input.as_bytes());
assert!(result.is_ok());
}
#[test]
fn load_script_empty() {
let input = "";
let result = load_script(input.as_bytes());
assert!(result.is_ok());
assert!(result.unwrap_or(vec![ScriptAction::Comment]).is_empty());
}
}

View File

@@ -0,0 +1 @@
pub mod action_fd;