feat(core): Add action-fd headless scripting protocol.
This commit is contained in:
146
crates/pikl-core/src/script/action_fd/error.rs
Normal file
146
crates/pikl-core/src/script/action_fd/error.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
107
crates/pikl-core/src/script/action_fd/mod.rs
Normal file
107
crates/pikl-core/src/script/action_fd/mod.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
564
crates/pikl-core/src/script/action_fd/parse.rs
Normal file
564
crates/pikl-core/src/script/action_fd/parse.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1
crates/pikl-core/src/script/mod.rs
Normal file
1
crates/pikl-core/src/script/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod action_fd;
|
||||||
Reference in New Issue
Block a user