From c74e4ea9fb2e74732ca92a25543184ba3a68e89b Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Fri, 13 Mar 2026 21:56:42 -0400 Subject: [PATCH] feat(core): Add runtime engine, input parsing, and hook trait. --- crates/pikl-core/src/runtime/hook.rs | 15 + crates/pikl-core/src/runtime/input.rs | 117 ++++ crates/pikl-core/src/runtime/json_menu.rs | 67 +++ crates/pikl-core/src/runtime/menu.rs | 648 ++++++++++++++++++++++ crates/pikl-core/src/runtime/mod.rs | 4 + 5 files changed, 851 insertions(+) create mode 100644 crates/pikl-core/src/runtime/hook.rs create mode 100644 crates/pikl-core/src/runtime/input.rs create mode 100644 crates/pikl-core/src/runtime/json_menu.rs create mode 100644 crates/pikl-core/src/runtime/menu.rs create mode 100644 crates/pikl-core/src/runtime/mod.rs diff --git a/crates/pikl-core/src/runtime/hook.rs b/crates/pikl-core/src/runtime/hook.rs new file mode 100644 index 0000000..802090a --- /dev/null +++ b/crates/pikl-core/src/runtime/hook.rs @@ -0,0 +1,15 @@ +//! Hook trait for lifecycle events. The core library defines +//! the interface; concrete implementations (shell hooks, IPC +//! hooks, etc.) live in frontend crates. + +use serde_json::Value; + +use crate::error::PiklError; + +/// A lifecycle hook that fires on menu events. Implementations +/// live outside pikl-core (e.g. in the CLI binary) so the core +/// library stays free of process/libc deps. +#[allow(async_fn_in_trait)] +pub trait Hook: Send + Sync { + async fn run(&self, value: &Value) -> Result<(), PiklError>; +} diff --git a/crates/pikl-core/src/runtime/input.rs b/crates/pikl-core/src/runtime/input.rs new file mode 100644 index 0000000..62a78d9 --- /dev/null +++ b/crates/pikl-core/src/runtime/input.rs @@ -0,0 +1,117 @@ +//! Input parsing. Reads lines from stdin (or any reader) +//! and turns them into [`Item`]s. Each line is tried as +//! JSON first. If that doesn't parse, it's treated as plain +//! text. Empty lines are skipped. + +use tokio::io::AsyncBufReadExt; + +use crate::error::PiklError; +use crate::item::Item; + +/// Try to parse a line as JSON. Falls back to wrapping +/// it as a plain-text string. The `label_key` controls +/// which JSON key is used as the display label for object +/// items. +fn parse_line(line: &str, label_key: &str) -> Item { + match serde_json::from_str::(line) { + Ok(value) => Item::new(value, label_key), + Err(_) => Item::from_plain_text(line), + } +} + +/// Read items from a synchronous reader. Use this for stdin so tokio +/// never registers the pipe fd. Avoids conflicts with crossterm's +/// event polling on fd 0 after dup2. +pub fn read_items_sync( + reader: impl std::io::BufRead, + label_key: &str, +) -> Result, PiklError> { + let mut items = Vec::new(); + for line in reader.lines() { + let line = line?; + if line.is_empty() { + continue; + } + items.push(parse_line(&line, label_key)); + } + Ok(items) +} + +/// Async version of [`read_items_sync`]. For streaming +/// input sources where items arrive over time. +pub async fn read_items( + reader: impl tokio::io::AsyncBufRead + Unpin, + label_key: &str, +) -> Result, PiklError> { + let mut items = Vec::new(); + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + if line.is_empty() { + continue; + } + items.push(parse_line(&line, label_key)); + } + Ok(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn parse(input: &str) -> Vec { + let reader = tokio::io::BufReader::new(input.as_bytes()); + read_items(reader, "label").await.unwrap_or_default() + } + + #[tokio::test] + async fn plain_text_lines() { + let items = parse("foo\nbar\nbaz\n").await; + assert_eq!(items.len(), 3); + assert_eq!(items[0].label(), "foo"); + assert_eq!(items[1].label(), "bar"); + assert_eq!(items[2].label(), "baz"); + } + + #[tokio::test] + async fn json_objects() { + let items = parse("{\"label\": \"one\"}\n{\"label\": \"two\"}\n").await; + assert_eq!(items.len(), 2); + assert_eq!(items[0].label(), "one"); + assert_eq!(items[1].label(), "two"); + } + + #[tokio::test] + async fn mixed_input() { + let items = parse("plain line\n{\"label\": \"json\"}\nanother plain\n").await; + assert_eq!(items.len(), 3); + assert_eq!(items[0].label(), "plain line"); + assert_eq!(items[1].label(), "json"); + assert_eq!(items[2].label(), "another plain"); + } + + #[tokio::test] + async fn skips_empty_lines() { + let items = parse("foo\n\n\nbar\n").await; + assert_eq!(items.len(), 2); + } + + #[tokio::test] + async fn invalid_json_treated_as_text() { + let items = parse("{not valid json}\n").await; + assert_eq!(items.len(), 1); + assert_eq!(items[0].label(), "{not valid json}"); + } + + #[tokio::test] + async fn empty_input() { + let items = parse("").await; + assert!(items.is_empty()); + } + + #[tokio::test] + async fn json_string_values() { + let items = parse("\"quoted string\"\n").await; + assert_eq!(items.len(), 1); + assert_eq!(items[0].label(), "quoted string"); + } +} diff --git a/crates/pikl-core/src/runtime/json_menu.rs b/crates/pikl-core/src/runtime/json_menu.rs new file mode 100644 index 0000000..ee81d91 --- /dev/null +++ b/crates/pikl-core/src/runtime/json_menu.rs @@ -0,0 +1,67 @@ +//! JSON-backed menu implementation. Wraps `Vec` with +//! fuzzy filtering via nucleo. This is the default backend +//! for `ls | pikl` style usage. + +use crate::filter::{Filter, FuzzyFilter}; +use crate::item::Item; +use crate::model::traits::Menu; + +/// A menu backed by a flat list of JSON items. Handles +/// filtering internally using the [`Filter`] trait (fuzzy +/// by default). The `label_key` controls which JSON key +/// is used for display labels on object items. +pub struct JsonMenu { + items: Vec, + label_key: String, + filter: Box, +} + +impl JsonMenu { + /// Create a new JSON menu with the given items and label key. + pub fn new(items: Vec, label_key: String) -> Self { + let mut filter = Box::new(FuzzyFilter::new()); + for (i, item) in items.iter().enumerate() { + filter.push(i, item.label()); + } + Self { + items, + label_key, + filter, + } + } +} + +impl Menu for JsonMenu { + fn total(&self) -> usize { + self.items.len() + } + + fn apply_filter(&mut self, query: &str) { + self.filter.set_query(query); + } + + fn filtered_count(&self) -> usize { + self.filter.matched_count() + } + + fn filtered_label(&self, filtered_index: usize) -> Option<&str> { + self.filter + .matched_index(filtered_index) + .map(|idx| self.items[idx].label()) + } + + fn add_raw(&mut self, values: Vec) { + for value in values { + let idx = self.items.len(); + let item = Item::new(value, &self.label_key); + self.filter.push(idx, item.label()); + self.items.push(item); + } + } + + fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> { + self.filter + .matched_index(filtered_index) + .map(|idx| &self.items[idx].value) + } +} diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs new file mode 100644 index 0000000..c3ec5d6 --- /dev/null +++ b/crates/pikl-core/src/runtime/menu.rs @@ -0,0 +1,648 @@ +//! The main event loop. [`MenuRunner`] wraps any [`Menu`] +//! implementation, drives the action/event channel loop, +//! and broadcasts state changes. Frontends never mutate +//! state directly. They send actions and react to events. + +use std::sync::Arc; + +use tokio::sync::{broadcast, mpsc}; + +use crate::error::PiklError; +use crate::event::{Action, MenuEvent, MenuResult, ViewState, VisibleItem}; +use crate::model::traits::Menu; +use crate::navigation::Viewport; +use serde_json::Value; + +/// Result of applying a single action to the menu state. +/// The run loop uses this to decide what to broadcast. +#[derive(Debug)] +pub enum ActionOutcome { + /// State changed, broadcast to subscribers. + Broadcast, + /// User confirmed a selection. + Selected(Value), + /// User cancelled. + Cancelled, + /// Nothing happened (e.g. confirm on empty list). + NoOp, +} + +/// The menu engine. Wraps any [`Menu`] implementation and +/// drives it with an action/event channel loop. Create one, +/// grab the action sender and event subscriber, then call +/// [`MenuRunner::run`] to start the event loop. +pub struct MenuRunner { + menu: M, + viewport: Viewport, + filter_text: Arc, + action_rx: mpsc::Receiver, + event_tx: broadcast::Sender, +} + +impl MenuRunner { + /// Create a menu runner wrapping the given menu backend. + /// Returns the runner and an action sender. Call + /// [`subscribe`](Self::subscribe) to get an event handle, + /// then [`run`](Self::run) to start the event loop. + pub fn new(menu: M) -> (Self, mpsc::Sender) { + let (action_tx, action_rx) = mpsc::channel(256); + // 1024 slots: large enough that a burst of rapid state changes + // (e.g. streaming AddItems + filter updates) won't cause lag for + // subscribers. If a subscriber does fall behind, it gets a Lagged + // error and can catch up from the next StateChanged. + let (event_tx, _) = broadcast::channel(1024); + let runner = Self { + menu, + viewport: Viewport::new(), + filter_text: Arc::from(""), + action_rx, + event_tx, + }; + (runner, action_tx) + } + + /// Subscribe to menu events. Returns a broadcast receiver + /// that gets state changes, selections, and cancellations. + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + /// Re-run the filter against all items with the current + /// filter text. Updates the viewport with the new count. + fn run_filter(&mut self) { + self.menu.apply_filter(&self.filter_text); + self.viewport.set_filtered_count(self.menu.filtered_count()); + } + + /// Build a [`ViewState`] snapshot from the current filter + /// results and viewport position. + fn build_view_state(&self) -> ViewState { + let range = self.viewport.visible_range(); + let visible_items: Vec = range + .clone() + .filter_map(|i| { + self.menu.filtered_label(i).map(|label| VisibleItem { + label: label.to_string(), + index: i, + }) + }) + .collect(); + + let cursor = if self.menu.filtered_count() == 0 { + 0 + } else { + self.viewport.cursor() - range.start + }; + + ViewState { + visible_items, + cursor, + filter_text: Arc::clone(&self.filter_text), + total_items: self.menu.total(), + total_filtered: self.menu.filtered_count(), + } + } + + /// Send the current view state to all subscribers. + fn broadcast_state(&self) { + let _ = self + .event_tx + .send(MenuEvent::StateChanged(self.build_view_state())); + } + + /// Apply a single action to the menu state. Pure state + /// transition: no channels, no async. Testable in isolation. + pub fn apply_action(&mut self, action: Action) -> ActionOutcome { + match action { + Action::UpdateFilter(text) => { + self.filter_text = Arc::from(text); + self.run_filter(); + ActionOutcome::Broadcast + } + Action::MoveUp(n) => { + self.viewport.move_up(n); + ActionOutcome::Broadcast + } + Action::MoveDown(n) => { + self.viewport.move_down(n); + ActionOutcome::Broadcast + } + Action::MoveToTop => { + self.viewport.move_to_top(); + ActionOutcome::Broadcast + } + Action::MoveToBottom => { + self.viewport.move_to_bottom(); + ActionOutcome::Broadcast + } + Action::PageUp(n) => { + self.viewport.page_up(n); + ActionOutcome::Broadcast + } + Action::PageDown(n) => { + self.viewport.page_down(n); + ActionOutcome::Broadcast + } + Action::Confirm => { + if self.menu.filtered_count() == 0 { + return ActionOutcome::NoOp; + } + let cursor = self.viewport.cursor(); + match self.menu.serialize_filtered(cursor) { + Some(value) => ActionOutcome::Selected(value.clone()), + None => ActionOutcome::NoOp, + } + } + Action::Cancel => ActionOutcome::Cancelled, + Action::Resize { height } => { + self.viewport.set_height(height as usize); + ActionOutcome::Broadcast + } + Action::AddItems(values) => { + self.menu.add_raw(values); + self.run_filter(); + ActionOutcome::Broadcast + } + } + } + + /// Run the menu event loop. Consumes actions and + /// broadcasts events. + /// + /// **Ordering guarantee:** Actions are processed + /// sequentially in the order received. Each action's + /// state change is fully applied before the next action + /// begins. A `Confirm` sent right after an + /// `UpdateFilter` will always select from the filtered + /// results, never stale pre-filter state. + /// + /// This holds regardless of how actions are sent (TUI + /// keypresses, headless scripts, programmatic sends). + /// It's enforced by the single `recv()` loop below and + /// must be preserved by any future refactors. + pub async fn run(mut self) -> Result { + self.run_filter(); + self.broadcast_state(); + + while let Some(action) = self.action_rx.recv().await { + match self.apply_action(action) { + ActionOutcome::Broadcast => self.broadcast_state(), + ActionOutcome::Selected(value) => { + let _ = self.event_tx.send(MenuEvent::Selected(value.clone())); + return Ok(MenuResult::Selected(value)); + } + ActionOutcome::Cancelled => { + let _ = self.event_tx.send(MenuEvent::Cancelled); + return Ok(MenuResult::Cancelled); + } + ActionOutcome::NoOp => {} + } + } + + // Sender dropped + Ok(MenuResult::Cancelled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::MenuEvent; + use crate::item::Item; + use crate::runtime::json_menu::JsonMenu; + + fn test_menu() -> (MenuRunner, mpsc::Sender) { + let items = vec![ + Item::from_plain_text("alpha"), + Item::from_plain_text("beta"), + Item::from_plain_text("gamma"), + Item::from_plain_text("delta"), + ]; + MenuRunner::new(JsonMenu::new(items, "label".to_string())) + } + + /// Set up a menu runner with filter applied and viewport sized. + /// Ready for sync apply_action tests. + fn ready_menu() -> MenuRunner { + let (mut m, _tx) = test_menu(); + m.run_filter(); + m.apply_action(Action::Resize { height: 10 }); + m + } + + // -- Sync apply_action tests -- + + #[test] + fn apply_move_down_updates_viewport() { + let mut m = ready_menu(); + let outcome = m.apply_action(Action::MoveDown(1)); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.viewport.cursor(), 1); + } + + #[test] + fn apply_move_up_updates_viewport() { + let mut m = ready_menu(); + m.apply_action(Action::MoveDown(2)); + let outcome = m.apply_action(Action::MoveUp(1)); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.viewport.cursor(), 1); + } + + #[test] + fn apply_filter_changes_results() { + let mut m = ready_menu(); + let outcome = m.apply_action(Action::UpdateFilter("al".to_string())); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(&*m.filter_text, "al"); + // alpha matches "al" + assert!(m.menu.filtered_count() >= 1); + } + + #[test] + fn apply_confirm_returns_selected() { + let mut m = ready_menu(); + m.apply_action(Action::MoveDown(1)); + let outcome = m.apply_action(Action::Confirm); + assert!(matches!(&outcome, ActionOutcome::Selected(v) if v.as_str() == Some("beta"))); + } + + #[test] + fn apply_confirm_on_empty_is_noop() { + let mut m = ready_menu(); + m.apply_action(Action::UpdateFilter("zzzzz".to_string())); + assert_eq!(m.menu.filtered_count(), 0); + let outcome = m.apply_action(Action::Confirm); + assert!(matches!(outcome, ActionOutcome::NoOp)); + } + + #[test] + fn apply_cancel_returns_cancelled() { + let mut m = ready_menu(); + let outcome = m.apply_action(Action::Cancel); + assert!(matches!(outcome, ActionOutcome::Cancelled)); + } + + #[test] + fn apply_resize_updates_viewport() { + let mut m = ready_menu(); + let outcome = m.apply_action(Action::Resize { height: 3 }); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.viewport.visible_range(), 0..3); + } + + #[test] + fn apply_add_items_runs_filter() { + let mut m = ready_menu(); + assert_eq!(m.menu.total(), 4); + let outcome = m.apply_action(Action::AddItems(vec![serde_json::Value::String( + "epsilon".to_string(), + )])); + assert!(matches!(outcome, ActionOutcome::Broadcast)); + assert_eq!(m.menu.total(), 5); + assert_eq!(m.menu.filtered_count(), 5); + } + + #[test] + fn apply_move_to_top_and_bottom() { + let mut m = ready_menu(); + m.apply_action(Action::MoveToBottom); + assert_eq!(m.viewport.cursor(), 3); + m.apply_action(Action::MoveToTop); + assert_eq!(m.viewport.cursor(), 0); + } + + #[test] + fn apply_page_movement() { + let (mut m, _tx) = test_menu(); + m.run_filter(); + m.apply_action(Action::Resize { height: 2 }); + m.apply_action(Action::PageDown(1)); + assert_eq!(m.viewport.cursor(), 2); + m.apply_action(Action::PageUp(1)); + assert_eq!(m.viewport.cursor(), 0); + } + + #[tokio::test] + async fn initial_state_broadcast() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + // Should receive initial state + if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { + assert_eq!(vs.total_items, 4); + assert_eq!(vs.total_filtered, 4); + assert_eq!(vs.cursor, 0); + assert!(vs.filter_text.is_empty()); + } + + // Cancel to exit + let _ = tx.send(Action::Cancel).await; + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Cancelled))); + } + + #[tokio::test] + async fn filter_updates() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + // Skip initial state + let _ = rx.recv().await; + + // Filter to "al" + let _ = tx.send(Action::UpdateFilter("al".to_string())).await; + if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { + assert_eq!(vs.total_items, 4); + // alpha should match "al" + assert!(vs.total_filtered >= 1); + assert_eq!(&*vs.filter_text, "al"); + } + + let _ = tx.send(Action::Cancel).await; + let _ = handle.await; + } + + #[tokio::test] + async fn confirm_selection() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + // Skip initial state + let _ = rx.recv().await; + + // Need to send resize first so viewport has height + let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = rx.recv().await; + + // Move down and confirm + let _ = tx.send(Action::MoveDown(1)).await; + let _ = rx.recv().await; + let _ = tx.send(Action::Confirm).await; + + // Should get Selected event + if let Ok(MenuEvent::Selected(value)) = rx.recv().await { + assert_eq!(value.as_str(), Some("beta")); + } + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Selected(_)))); + } + + #[tokio::test] + async fn confirm_on_empty_is_noop() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + + // Filter to something that matches nothing + let _ = tx.send(Action::UpdateFilter("zzzzz".to_string())).await; + if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { + assert_eq!(vs.total_filtered, 0); + } + + // Confirm should be no-op + let _ = tx.send(Action::Confirm).await; + + // Cancel to exit (should still work) + let _ = tx.send(Action::Cancel).await; + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Cancelled))); + } + + #[tokio::test] + async fn sender_drop_cancels() { + let (menu, tx) = test_menu(); + let _rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + // Drop the only sender. + drop(tx); + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Cancelled))); + } + + // -- End-to-end output correctness -- + + #[tokio::test] + async fn confirm_first_item_value() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial state + let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = rx.recv().await; + + // Confirm at cursor 0, should get "alpha" + let _ = tx.send(Action::Confirm).await; + + let event = rx.recv().await; + assert!(matches!(&event, Ok(MenuEvent::Selected(v)) if v.as_str() == Some("alpha"))); + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("alpha"))); + } + + #[tokio::test] + async fn confirm_third_item_value() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = rx.recv().await; + + // Move down twice -> cursor at index 2 -> "gamma" + let _ = tx.send(Action::MoveDown(1)).await; + let _ = rx.recv().await; + let _ = tx.send(Action::MoveDown(1)).await; + let _ = rx.recv().await; + let _ = tx.send(Action::Confirm).await; + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("gamma"))); + } + + #[tokio::test] + async fn filter_then_confirm_correct_item() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = rx.recv().await; + + // Filter to "del", should match "delta" + let _ = tx.send(Action::UpdateFilter("del".to_string())).await; + if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { + assert!(vs.total_filtered >= 1); + assert_eq!(vs.visible_items[0].label, "delta"); + } + + // Confirm, should select "delta" + let _ = tx.send(Action::Confirm).await; + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta"))); + } + + #[tokio::test] + async fn add_items_then_confirm_new_item() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + let _ = tx.send(Action::Resize { height: 10 }).await; + let _ = rx.recv().await; + + // Add a new item + let _ = tx + .send(Action::AddItems(vec![serde_json::Value::String( + "epsilon".to_string(), + )])) + .await; + if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { + assert_eq!(vs.total_items, 5); + assert_eq!(vs.total_filtered, 5); + } + + // Filter to "eps", only epsilon should match + let _ = tx.send(Action::UpdateFilter("eps".to_string())).await; + if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { + assert!(vs.total_filtered >= 1); + assert_eq!(vs.visible_items[0].label, "epsilon"); + } + + let _ = tx.send(Action::Confirm).await; + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("epsilon"))); + } + + #[tokio::test] + async fn cancel_returns_no_item() { + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + + let _ = tx.send(Action::Cancel).await; + + // Should get Cancelled event + assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled))); + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + assert!(matches!(result, Ok(MenuResult::Cancelled))); + } + + // -- Ordering invariant tests -- + // These tests verify the sequential processing guarantee documented on + // MenuRunner::run(). Actions sent back-to-back without waiting for + // intermediate events must still be processed in order, each fully + // applied before the next begins. + + #[tokio::test] + async fn actions_process_in_order_filter_then_confirm() { + // Send filter + confirm back-to-back with no recv between them. + // Confirm must act on the filtered results, not the pre-filter state. + let items = vec![ + Item::from_plain_text("alpha"), + Item::from_plain_text("beta"), + Item::from_plain_text("banana"), + ]; + let (menu, tx) = MenuRunner::new(JsonMenu::new(items, "label".to_string())); + let mut rx = menu.subscribe(); + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = rx.recv().await; + + // Back-to-back, no waiting between these + let _ = tx.send(Action::UpdateFilter("ban".to_string())).await; + let _ = tx.send(Action::Confirm).await; + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + // Must get "banana". Filter was applied before confirm ran. + assert!(matches!( + result, + Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("banana") + )); + } + + #[tokio::test] + async fn actions_process_in_order_move_then_confirm() { + // Send multiple moves + confirm back-to-back. + // Confirm must reflect the final cursor position. + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = rx.recv().await; + + // Three moves down back-to-back, then confirm + let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(Action::MoveDown(1)).await; + let _ = tx.send(Action::Confirm).await; + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + // Cursor at index 3 -> "delta" + assert!(matches!( + result, + Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("delta") + )); + } + + #[tokio::test] + async fn actions_process_in_order_add_items_then_filter_then_confirm() { + // AddItems + filter + confirm, all back-to-back. + // The filter must see the newly added items. + let (menu, tx) = test_menu(); + let mut rx = menu.subscribe(); + let handle = tokio::spawn(async move { menu.run().await }); + + let _ = rx.recv().await; // initial + let _ = tx.send(Action::Resize { height: 50 }).await; + let _ = rx.recv().await; + + // All back-to-back + let _ = tx + .send(Action::AddItems(vec![serde_json::Value::String( + "zephyr".to_string(), + )])) + .await; + let _ = tx.send(Action::UpdateFilter("zep".to_string())).await; + let _ = tx.send(Action::Confirm).await; + + let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); + // Must find "zephyr". It was added before the filter ran. + assert!(matches!( + result, + Ok(MenuResult::Selected(ref v)) if v.as_str() == Some("zephyr") + )); + } +} diff --git a/crates/pikl-core/src/runtime/mod.rs b/crates/pikl-core/src/runtime/mod.rs new file mode 100644 index 0000000..90731f6 --- /dev/null +++ b/crates/pikl-core/src/runtime/mod.rs @@ -0,0 +1,4 @@ +pub mod hook; +pub mod input; +pub mod json_menu; +pub mod menu;