From cb7911e5c911a4d029702c7589b4f38a195454c4 Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Fri, 13 Mar 2026 21:57:30 -0400 Subject: [PATCH] feat(core): Add action-fd headless scripting protocol. --- .../pikl-core/src/script/action_fd/error.rs | 146 +++++ crates/pikl-core/src/script/action_fd/mod.rs | 107 ++++ .../pikl-core/src/script/action_fd/parse.rs | 564 ++++++++++++++++++ crates/pikl-core/src/script/mod.rs | 1 + 4 files changed, 818 insertions(+) create mode 100644 crates/pikl-core/src/script/action_fd/error.rs create mode 100644 crates/pikl-core/src/script/action_fd/mod.rs create mode 100644 crates/pikl-core/src/script/action_fd/parse.rs create mode 100644 crates/pikl-core/src/script/mod.rs diff --git a/crates/pikl-core/src/script/action_fd/error.rs b/crates/pikl-core/src/script/action_fd/error.rs new file mode 100644 index 0000000..d21b291 --- /dev/null +++ b/crates/pikl-core/src/script/action_fd/error.rs @@ -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 { + 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} ", + ) + } + 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")); + } +} diff --git a/crates/pikl-core/src/script/action_fd/mod.rs b/crates/pikl-core/src/script/action_fd/mod.rs new file mode 100644 index 0000000..189a1e3 --- /dev/null +++ b/crates/pikl-core/src/script/action_fd/mod.rs @@ -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, + tx: &mpsc::Sender, +) -> Result, 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)); + } +} diff --git a/crates/pikl-core/src/script/action_fd/parse.rs b/crates/pikl-core/src/script/action_fd/parse.rs new file mode 100644 index 0000000..274b80d --- /dev/null +++ b/crates/pikl-core/src/script/action_fd/parse.rs @@ -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 { + match arg { + None => Ok(1), + Some(s) => match s.trim().parse::() { + 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 { + let Some(s) = arg else { + return Err(invalid_arg( + line_number, + line, + action, + format!("missing {action} value"), + )); + }; + match s.trim().parse::() { + 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 { + 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, 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 = 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()); + } +} diff --git a/crates/pikl-core/src/script/mod.rs b/crates/pikl-core/src/script/mod.rs new file mode 100644 index 0000000..443dc2e --- /dev/null +++ b/crates/pikl-core/src/script/mod.rs @@ -0,0 +1 @@ +pub mod action_fd;