feat(core): Add error types, config, and data model for items and events.

This commit is contained in:
2026-03-13 21:55:11 -04:00
parent de0431778f
commit 9ed8e898a5
7 changed files with 324 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
//! Runtime configuration for a menu instance. Built from CLI
//! args or programmatic defaults. This is not the on-disk
//! config file (that's a future thing).
/// Controls how the menu behaves for a single invocation.
pub struct MenuConfig {
/// JSON key used to extract display labels from object
/// items. Ignored for plain-text (string) items.
pub label_key: String,
/// Shell command to run when the user confirms a selection.
/// The selected item's JSON is piped to stdin.
pub on_select: Option<String>,
/// Shell command to run when the user cancels.
pub on_cancel: Option<String>,
}
impl Default for MenuConfig {
/// Returns a config with `label_key` set to `"label"` and
/// no hooks.
fn default() -> Self {
Self {
label_key: "label".to_string(),
on_select: None,
on_cancel: None,
}
}
}

View File

@@ -0,0 +1,25 @@
//! Error types for pikl-core. Every failure mode gets its own
//! variant. No stringly typed errors, no anyhow.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PiklError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("hook failed: {command} exited with {status}")]
HookFailed {
command: String,
status: std::process::ExitStatus,
},
#[error("channel closed")]
ChannelClosed,
#[error("{0}")]
Script(#[from] crate::script::action_fd::ScriptError),
}

View File

@@ -0,0 +1,22 @@
//! Core engine for pikl-menu. Handles filtering, navigation,
//! hooks, and the menu event loop. No rendering or terminal
//! deps. Frontends are separate crates that talk to this
//! through channels.
mod model;
mod query;
mod runtime;
pub mod script;
pub mod error;
// Re-export submodules at crate root so the public API stays flat.
pub use model::event;
pub use model::item;
pub use model::traits;
pub use query::filter;
pub use query::navigation;
pub use runtime::hook;
pub use runtime::input;
pub use runtime::json_menu;
pub use runtime::menu;

View File

@@ -0,0 +1,73 @@
//! Types that flow between the menu engine and frontends.
//!
//! - [`Action`]: commands sent into the menu (keypresses,
//! script actions).
//! - [`MenuEvent`]: notifications sent out to subscribers
//! (state changes, terminal events).
//! - [`ViewState`]: snapshot of what the frontend should
//! render right now.
use std::sync::Arc;
use serde_json::Value;
/// A command the menu should process. Frontends and headless
/// scripts both produce these. The menu loop consumes them
/// sequentially.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
UpdateFilter(String),
MoveUp(usize),
MoveDown(usize),
MoveToTop,
MoveToBottom,
PageUp(usize),
PageDown(usize),
Confirm,
Cancel,
Resize { height: u16 },
AddItems(Vec<Value>),
}
/// Broadcast from the menu loop to all subscribers
/// (frontends, tests).
#[derive(Debug, Clone)]
pub enum MenuEvent {
StateChanged(ViewState),
Selected(Value),
Cancelled,
}
/// Snapshot of the menu's visible state. Sent on every state
/// change so frontends can render without querying back into
/// the engine.
#[must_use]
#[derive(Debug, Clone)]
pub struct ViewState {
pub visible_items: Vec<VisibleItem>,
/// Index of the cursor within `visible_items`
/// (not the full list).
pub cursor: usize,
pub filter_text: Arc<str>,
pub total_items: usize,
pub total_filtered: usize,
}
/// A single item in the current viewport window. Has the
/// display label pre-resolved and the position in the
/// filtered list.
#[must_use]
#[derive(Debug, Clone)]
pub struct VisibleItem {
pub label: String,
pub index: usize,
}
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
/// user either picked something or bailed.
#[must_use]
#[derive(Debug)]
pub enum MenuResult {
Selected(Value),
Cancelled,
}

View File

@@ -0,0 +1,131 @@
//! The core data unit. An [`Item`] is a single entry in the
//! menu, either a plain-text string or a JSON object with
//! structured fields.
use serde_json::Value;
/// A menu entry wrapping a JSON value. Plain text is stored
/// as `Value::String`, structured entries as
/// `Value::Object`. The label is extracted and cached at
/// construction time so display calls are just a pointer
/// dereference.
///
/// Serializes as the inner Value (transparent for output)
/// but construction always requires label extraction.
#[derive(Debug, Clone)]
pub struct Item {
pub value: Value,
label_cache: String,
}
impl Item {
/// Create an Item from a JSON value, extracting the display
/// label from the given key. String values use the string
/// itself. Object values look up the key.
pub fn new(value: Value, label_key: &str) -> Self {
let label_cache = extract_label(&value, label_key).to_string();
Self { value, label_cache }
}
/// Wrap a plain-text string as an Item. Stored internally
/// as `Value::String` with the label cached.
pub fn from_plain_text(line: &str) -> Self {
Self {
value: Value::String(line.to_string()),
label_cache: line.to_string(),
}
}
/// Get the display label for this item. Cached at
/// construction time, so this is just a borrow.
pub fn label(&self) -> &str {
&self.label_cache
}
}
/// Extract the display label from a JSON value.
fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str {
match value {
Value::String(s) => s.as_str(),
Value::Object(map) => map.get(key).and_then(|v| v.as_str()).unwrap_or(""),
_ => "",
}
}
impl serde::Serialize for Item {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.value.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for Item {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
// Default to "label" key when deserializing. Callers that need
// a different key should construct via Item::new() instead.
Ok(Item::new(value, "label"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn label_from_string() {
let item = Item::new(json!("hello"), "label");
assert_eq!(item.label(), "hello");
}
#[test]
fn label_from_object_with_key() {
let item = Item::new(json!({"label": "foo", "value": 42}), "label");
assert_eq!(item.label(), "foo");
}
#[test]
fn label_from_object_missing_key() {
let item = Item::new(json!({"name": "bar"}), "label");
assert_eq!(item.label(), "");
}
#[test]
fn label_from_object_custom_key() {
let item = Item::new(json!({"name": "bar", "label": "ignored"}), "name");
assert_eq!(item.label(), "bar");
}
#[test]
fn label_from_number() {
let item = Item::new(json!(42), "label");
assert_eq!(item.label(), "");
}
#[test]
fn label_from_null() {
let item = Item::new(json!(null), "label");
assert_eq!(item.label(), "");
}
#[test]
fn from_plain_text() {
let item = Item::from_plain_text("hello world");
assert_eq!(item.label(), "hello world");
assert!(item.value.is_string());
}
#[test]
fn serialize_string_item() {
let item = Item::from_plain_text("test");
let json = serde_json::to_string(&item).unwrap_or_default();
assert_eq!(json, "\"test\"");
}
#[test]
fn serialize_object_item() {
let item = Item::new(json!({"label": "foo"}), "label");
let json = serde_json::to_string(&item).unwrap_or_default();
assert_eq!(json, r#"{"label":"foo"}"#);
}
}

View File

@@ -0,0 +1,3 @@
pub mod event;
pub mod item;
pub mod traits;

View File

@@ -0,0 +1,41 @@
//! Trait abstractions for the menu data layer. [`Menu`]
//! is what the event loop drives. [`MenuItem`] is the display
//! contract for a single entry. Different backends (JSON,
//! CSV, Postgres, etc.) implement these.
/// Display contract for a single entry in a menu.
/// Provides a label for display and serialization for output.
pub trait MenuItem: Send + Sync + 'static {
/// Human-readable display text for this entry.
fn label(&self) -> &str;
/// Serialize this entry for structured output (stdout, hooks).
fn serialize(&self) -> serde_json::Value;
}
/// A filterable collection that the menu event loop drives.
/// Each implementation owns its data and handles filtering
/// internally. The event loop only needs labels (strings)
/// and serialized output (JSON values).
pub trait Menu: Send + 'static {
/// Total number of items before filtering.
fn total(&self) -> usize;
/// Apply a filter query. Implementations decide how to
/// match (fuzzy, regex, exact, SQL WHERE, etc.).
fn apply_filter(&mut self, query: &str);
/// Number of items that passed the current filter.
fn filtered_count(&self) -> usize;
/// Get the display label for a filtered item by its
/// position in the filtered results.
fn filtered_label(&self, filtered_index: usize) -> Option<&str>;
/// Add raw values from streaming input or AddItems actions.
fn add_raw(&mut self, values: Vec<serde_json::Value>);
/// Get the JSON value of a filtered item for output.
/// Returns a reference to the stored value.
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
}