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