diff --git a/Cargo.lock b/Cargo.lock index 7092a6bd..a7a01f40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7994,6 +7994,17 @@ dependencies = [ "rustix 1.0.7", ] +[[package]] +name = "yaak-actions" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "ts-rs", +] + [[package]] name = "yaak-app" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 18a45a58..da97dba1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ # Shared crates (no Tauri dependency) + "crates/yaak-actions", "crates/yaak-core", "crates/yaak-common", "crates/yaak-crypto", @@ -45,6 +46,7 @@ tokio = "1.48.0" ts-rs = "11.1.0" # Internal crates - shared +yaak-actions = { path = "crates/yaak-actions" } yaak-core = { path = "crates/yaak-core" } yaak-common = { path = "crates/yaak-common" } yaak-crypto = { path = "crates/yaak-crypto" } diff --git a/crates/yaak-actions/Cargo.toml b/crates/yaak-actions/Cargo.toml new file mode 100644 index 00000000..193bec02 --- /dev/null +++ b/crates/yaak-actions/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "yaak-actions" +version = "0.1.0" +edition = "2021" +description = "Centralized action system for Yaak" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +ts-rs = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/yaak-actions/bindings/ActionAvailability.ts b/crates/yaak-actions/bindings/ActionAvailability.ts new file mode 100644 index 00000000..cb443540 --- /dev/null +++ b/crates/yaak-actions/bindings/ActionAvailability.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Availability status for an action. + */ +export type ActionAvailability = { "status": "available" } | { "status": "available-with-prompt", +/** + * Fields that will require prompting. + */ +prompt_fields: Array, } | { "status": "unavailable", +/** + * Fields that are missing. + */ +missing_fields: Array, } | { "status": "not-found" }; diff --git a/crates/yaak-actions/bindings/ActionError.ts b/crates/yaak-actions/bindings/ActionError.ts new file mode 100644 index 00000000..90c6b7d8 --- /dev/null +++ b/crates/yaak-actions/bindings/ActionError.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionGroupId } from "./ActionGroupId"; +import type { ActionId } from "./ActionId"; +import type { ActionScope } from "./ActionScope"; + +/** + * Errors that can occur during action operations. + */ +export type ActionError = { "type": "not-found" } & ActionId | { "type": "disabled", action_id: ActionId, reason: string, } | { "type": "invalid-scope", expected: ActionScope, actual: ActionScope, } | { "type": "timeout" } & ActionId | { "type": "plugin-error" } & string | { "type": "validation-error" } & string | { "type": "permission-denied" } & string | { "type": "cancelled" } | { "type": "internal" } & string | { "type": "context-missing", +/** + * The context fields that are missing. + */ +missing_fields: Array, } | { "type": "group-not-found" } & ActionGroupId | { "type": "group-already-exists" } & ActionGroupId; diff --git a/crates/yaak-actions/bindings/ActionGroupId.ts b/crates/yaak-actions/bindings/ActionGroupId.ts new file mode 100644 index 00000000..e043738c --- /dev/null +++ b/crates/yaak-actions/bindings/ActionGroupId.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Unique identifier for an action group. + * + * Format: `namespace:group-name` + * - Built-in: `yaak:export` + * - Plugin: `plugin.my-plugin:utilities` + */ +export type ActionGroupId = string; diff --git a/crates/yaak-actions/bindings/ActionGroupMetadata.ts b/crates/yaak-actions/bindings/ActionGroupMetadata.ts new file mode 100644 index 00000000..8f933327 --- /dev/null +++ b/crates/yaak-actions/bindings/ActionGroupMetadata.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionGroupId } from "./ActionGroupId"; +import type { ActionScope } from "./ActionScope"; + +/** + * Metadata about an action group. + */ +export type ActionGroupMetadata = { +/** + * Unique identifier for this group. + */ +id: ActionGroupId, +/** + * Display name for the group. + */ +name: string, +/** + * Optional description of the group's purpose. + */ +description: string | null, +/** + * Icon to display for the group. + */ +icon: string | null, +/** + * Sort order for displaying groups (lower = earlier). + */ +order: number, +/** + * Optional scope restriction (if set, group only appears in this scope). + */ +scope: ActionScope | null, }; diff --git a/crates/yaak-actions/bindings/ActionGroupSource.ts b/crates/yaak-actions/bindings/ActionGroupSource.ts new file mode 100644 index 00000000..ced2a2df --- /dev/null +++ b/crates/yaak-actions/bindings/ActionGroupSource.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Where an action group was registered from. + */ +export type ActionGroupSource = { "type": "builtin" } | { "type": "plugin", +/** + * Plugin reference ID. + */ +ref_id: string, +/** + * Plugin name. + */ +name: string, } | { "type": "dynamic", +/** + * Source identifier. + */ +source_id: string, }; diff --git a/crates/yaak-actions/bindings/ActionGroupWithActions.ts b/crates/yaak-actions/bindings/ActionGroupWithActions.ts new file mode 100644 index 00000000..f8b491c0 --- /dev/null +++ b/crates/yaak-actions/bindings/ActionGroupWithActions.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionGroupMetadata } from "./ActionGroupMetadata"; +import type { ActionMetadata } from "./ActionMetadata"; + +/** + * A group with its actions for UI rendering. + */ +export type ActionGroupWithActions = { +/** + * Group metadata. + */ +group: ActionGroupMetadata, +/** + * Actions in this group. + */ +actions: Array, }; diff --git a/crates/yaak-actions/bindings/ActionId.ts b/crates/yaak-actions/bindings/ActionId.ts new file mode 100644 index 00000000..cc08be62 --- /dev/null +++ b/crates/yaak-actions/bindings/ActionId.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Unique identifier for an action. + * + * Format: `namespace:category:name` + * - Built-in: `yaak:http-request:send` + * - Plugin: `plugin.copy-curl:http-request:copy` + */ +export type ActionId = string; diff --git a/crates/yaak-actions/bindings/ActionMetadata.ts b/crates/yaak-actions/bindings/ActionMetadata.ts new file mode 100644 index 00000000..0e7d002f --- /dev/null +++ b/crates/yaak-actions/bindings/ActionMetadata.ts @@ -0,0 +1,54 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionGroupId } from "./ActionGroupId"; +import type { ActionId } from "./ActionId"; +import type { ActionScope } from "./ActionScope"; +import type { RequiredContext } from "./RequiredContext"; + +/** + * Metadata about an action for discovery. + */ +export type ActionMetadata = { +/** + * Unique identifier for this action. + */ +id: ActionId, +/** + * Display label for the action. + */ +label: string, +/** + * Optional description of what the action does. + */ +description: string | null, +/** + * Icon name to display. + */ +icon: string | null, +/** + * The scope this action applies to. + */ +scope: ActionScope, +/** + * Keyboard shortcut (e.g., "Cmd+Enter"). + */ +keyboardShortcut: string | null, +/** + * Whether the action requires a selection/target. + */ +requiresSelection: boolean, +/** + * Optional condition expression for when action is enabled. + */ +enabledCondition: string | null, +/** + * Optional group this action belongs to. + */ +groupId: ActionGroupId | null, +/** + * Sort order within a group (lower = earlier). + */ +order: number, +/** + * Context requirements for this action. + */ +requiredContext: RequiredContext, }; diff --git a/crates/yaak-actions/bindings/ActionParams.ts b/crates/yaak-actions/bindings/ActionParams.ts new file mode 100644 index 00000000..bc9211db --- /dev/null +++ b/crates/yaak-actions/bindings/ActionParams.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Parameters passed to action handlers. + */ +export type ActionParams = { +/** + * Arbitrary JSON parameters. + */ +data: unknown, }; diff --git a/crates/yaak-actions/bindings/ActionResult.ts b/crates/yaak-actions/bindings/ActionResult.ts new file mode 100644 index 00000000..508dcb9b --- /dev/null +++ b/crates/yaak-actions/bindings/ActionResult.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InputPrompt } from "./InputPrompt"; + +/** + * Result of action execution. + */ +export type ActionResult = { "type": "success", +/** + * Optional data to return. + */ +data: unknown, +/** + * Optional message to display. + */ +message: string | null, } | { "type": "requires-input", +/** + * Prompt to show user. + */ +prompt: InputPrompt, +/** + * Continuation token. + */ +continuation_id: string, } | { "type": "cancelled" }; diff --git a/crates/yaak-actions/bindings/ActionScope.ts b/crates/yaak-actions/bindings/ActionScope.ts new file mode 100644 index 00000000..000a172b --- /dev/null +++ b/crates/yaak-actions/bindings/ActionScope.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The scope in which an action can be invoked. + */ +export type ActionScope = "global" | "http-request" | "websocket-request" | "grpc-request" | "workspace" | "folder" | "environment" | "cookie-jar"; diff --git a/crates/yaak-actions/bindings/ActionSource.ts b/crates/yaak-actions/bindings/ActionSource.ts new file mode 100644 index 00000000..98a72e8f --- /dev/null +++ b/crates/yaak-actions/bindings/ActionSource.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Where an action was registered from. + */ +export type ActionSource = { "type": "builtin" } | { "type": "plugin", +/** + * Plugin reference ID. + */ +ref_id: string, +/** + * Plugin name. + */ +name: string, } | { "type": "dynamic", +/** + * Source identifier. + */ +source_id: string, }; diff --git a/crates/yaak-actions/bindings/ActionTarget.ts b/crates/yaak-actions/bindings/ActionTarget.ts new file mode 100644 index 00000000..378998e8 --- /dev/null +++ b/crates/yaak-actions/bindings/ActionTarget.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The target entity for an action. + */ +export type ActionTarget = { "type": "none" } | { "type": "http-request", id: string, } | { "type": "websocket-request", id: string, } | { "type": "grpc-request", id: string, } | { "type": "workspace", id: string, } | { "type": "folder", id: string, } | { "type": "environment", id: string, } | { "type": "multiple", targets: Array, }; diff --git a/crates/yaak-actions/bindings/ContextRequirement.ts b/crates/yaak-actions/bindings/ContextRequirement.ts new file mode 100644 index 00000000..d8a2b94d --- /dev/null +++ b/crates/yaak-actions/bindings/ContextRequirement.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How strictly a context field is required. + */ +export type ContextRequirement = "not-required" | "optional" | "required" | "required-with-prompt"; diff --git a/crates/yaak-actions/bindings/CurrentContext.ts b/crates/yaak-actions/bindings/CurrentContext.ts new file mode 100644 index 00000000..0bef091d --- /dev/null +++ b/crates/yaak-actions/bindings/CurrentContext.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionTarget } from "./ActionTarget"; + +/** + * Current context state from the application. + */ +export type CurrentContext = { +/** + * Current workspace ID (if any). + */ +workspaceId: string | null, +/** + * Current environment ID (if any). + */ +environmentId: string | null, +/** + * Currently selected target (if any). + */ +target: ActionTarget | null, +/** + * Whether a window context is available. + */ +hasWindow: boolean, +/** + * Whether the context provider can prompt for missing fields. + */ +canPrompt: boolean, }; diff --git a/crates/yaak-actions/bindings/InputPrompt.ts b/crates/yaak-actions/bindings/InputPrompt.ts new file mode 100644 index 00000000..7083a30f --- /dev/null +++ b/crates/yaak-actions/bindings/InputPrompt.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SelectOption } from "./SelectOption"; + +/** + * A prompt for user input. + */ +export type InputPrompt = { "type": "text", label: string, placeholder: string | null, default_value: string | null, } | { "type": "select", label: string, options: Array, } | { "type": "confirm", label: string, }; diff --git a/crates/yaak-actions/bindings/RequiredContext.ts b/crates/yaak-actions/bindings/RequiredContext.ts new file mode 100644 index 00000000..bfb1a946 --- /dev/null +++ b/crates/yaak-actions/bindings/RequiredContext.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContextRequirement } from "./ContextRequirement"; + +/** + * Specifies what context fields an action requires. + */ +export type RequiredContext = { +/** + * Action requires a workspace to be active. + */ +workspace: ContextRequirement, +/** + * Action requires an environment to be selected. + */ +environment: ContextRequirement, +/** + * Action requires a specific target entity (request, folder, etc.). + */ +target: ContextRequirement, +/** + * Action requires a window context (for UI operations). + */ +window: ContextRequirement, }; diff --git a/crates/yaak-actions/bindings/SelectOption.ts b/crates/yaak-actions/bindings/SelectOption.ts new file mode 100644 index 00000000..4b5dd12a --- /dev/null +++ b/crates/yaak-actions/bindings/SelectOption.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An option in a select prompt. + */ +export type SelectOption = { label: string, value: string, }; diff --git a/crates/yaak-actions/src/context.rs b/crates/yaak-actions/src/context.rs new file mode 100644 index 00000000..6c2d1f60 --- /dev/null +++ b/crates/yaak-actions/src/context.rs @@ -0,0 +1,331 @@ +//! Action context types and context-aware filtering. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::ActionScope; + +/// Specifies what context fields an action requires. +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct RequiredContext { + /// Action requires a workspace to be active. + #[serde(default)] + pub workspace: ContextRequirement, + + /// Action requires an environment to be selected. + #[serde(default)] + pub environment: ContextRequirement, + + /// Action requires a specific target entity (request, folder, etc.). + #[serde(default)] + pub target: ContextRequirement, + + /// Action requires a window context (for UI operations). + #[serde(default)] + pub window: ContextRequirement, +} + +impl RequiredContext { + /// Action requires a target entity. + pub fn requires_target() -> Self { + Self { + target: ContextRequirement::Required, + ..Default::default() + } + } + + /// Action requires workspace and target. + pub fn requires_workspace_and_target() -> Self { + Self { + workspace: ContextRequirement::Required, + target: ContextRequirement::Required, + ..Default::default() + } + } + + /// Action works globally, no specific context needed. + pub fn global() -> Self { + Self::default() + } + + /// Action requires target with prompt if missing. + pub fn requires_target_with_prompt() -> Self { + Self { + target: ContextRequirement::RequiredWithPrompt, + ..Default::default() + } + } + + /// Action requires environment with prompt if missing. + pub fn requires_environment_with_prompt() -> Self { + Self { + environment: ContextRequirement::RequiredWithPrompt, + ..Default::default() + } + } +} + +/// How strictly a context field is required. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum ContextRequirement { + /// Field is not needed. + #[default] + NotRequired, + + /// Field is optional but will be used if available. + Optional, + + /// Field must be present; action will fail without it. + Required, + + /// Field must be present; prompt user to select if missing. + RequiredWithPrompt, +} + +/// Current context state from the application. +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct CurrentContext { + /// Current workspace ID (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + + /// Current environment ID (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub environment_id: Option, + + /// Currently selected target (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + + /// Whether a window context is available. + #[serde(default)] + pub has_window: bool, + + /// Whether the context provider can prompt for missing fields. + #[serde(default)] + pub can_prompt: bool, +} + +/// The target entity for an action. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ActionTarget { + /// No target. + None, + /// HTTP request target. + HttpRequest { id: String }, + /// WebSocket request target. + WebsocketRequest { id: String }, + /// gRPC request target. + GrpcRequest { id: String }, + /// Workspace target. + Workspace { id: String }, + /// Folder target. + Folder { id: String }, + /// Environment target. + Environment { id: String }, + /// Multiple targets. + Multiple { targets: Vec }, +} + +impl ActionTarget { + /// Get the scope this target corresponds to. + pub fn scope(&self) -> Option { + match self { + Self::None => None, + Self::HttpRequest { .. } => Some(ActionScope::HttpRequest), + Self::WebsocketRequest { .. } => Some(ActionScope::WebsocketRequest), + Self::GrpcRequest { .. } => Some(ActionScope::GrpcRequest), + Self::Workspace { .. } => Some(ActionScope::Workspace), + Self::Folder { .. } => Some(ActionScope::Folder), + Self::Environment { .. } => Some(ActionScope::Environment), + Self::Multiple { .. } => None, + } + } + + /// Get the ID of the target (if single target). + pub fn id(&self) -> Option<&str> { + match self { + Self::HttpRequest { id } + | Self::WebsocketRequest { id } + | Self::GrpcRequest { id } + | Self::Workspace { id } + | Self::Folder { id } + | Self::Environment { id } => Some(id), + Self::None | Self::Multiple { .. } => None, + } + } +} + +/// Availability status for an action. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "status", rename_all = "kebab-case")] +pub enum ActionAvailability { + /// Action is ready to execute. + Available, + + /// Action can execute but will prompt for missing context. + AvailableWithPrompt { + /// Fields that will require prompting. + prompt_fields: Vec, + }, + + /// Action cannot execute due to missing context. + Unavailable { + /// Fields that are missing. + missing_fields: Vec, + }, + + /// Action not found in registry. + NotFound, +} + +impl ActionAvailability { + /// Check if the action is available (possibly with prompts). + pub fn is_available(&self) -> bool { + matches!(self, Self::Available | Self::AvailableWithPrompt { .. }) + } + + /// Check if the action is immediately available without prompts. + pub fn is_immediately_available(&self) -> bool { + matches!(self, Self::Available) + } +} + +/// Check if required context is satisfied by current context. +pub fn check_context_availability( + required: &RequiredContext, + current: &CurrentContext, +) -> ActionAvailability { + let mut missing_fields = Vec::new(); + let mut prompt_fields = Vec::new(); + + // Check workspace + check_field( + "workspace", + current.workspace_id.is_some(), + &required.workspace, + current.can_prompt, + &mut missing_fields, + &mut prompt_fields, + ); + + // Check environment + check_field( + "environment", + current.environment_id.is_some(), + &required.environment, + current.can_prompt, + &mut missing_fields, + &mut prompt_fields, + ); + + // Check target + check_field( + "target", + current.target.is_some(), + &required.target, + current.can_prompt, + &mut missing_fields, + &mut prompt_fields, + ); + + // Check window + check_field( + "window", + current.has_window, + &required.window, + false, // Can't prompt for window + &mut missing_fields, + &mut prompt_fields, + ); + + if !missing_fields.is_empty() { + ActionAvailability::Unavailable { missing_fields } + } else if !prompt_fields.is_empty() { + ActionAvailability::AvailableWithPrompt { prompt_fields } + } else { + ActionAvailability::Available + } +} + +fn check_field( + name: &str, + has_value: bool, + requirement: &ContextRequirement, + can_prompt: bool, + missing: &mut Vec, + promptable: &mut Vec, +) { + match requirement { + ContextRequirement::NotRequired | ContextRequirement::Optional => {} + ContextRequirement::Required => { + if !has_value { + missing.push(name.to_string()); + } + } + ContextRequirement::RequiredWithPrompt => { + if !has_value { + if can_prompt { + promptable.push(name.to_string()); + } else { + missing.push(name.to_string()); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_context_available() { + let required = RequiredContext::requires_target(); + let current = CurrentContext { + target: Some(ActionTarget::HttpRequest { + id: "123".to_string(), + }), + ..Default::default() + }; + + let availability = check_context_availability(&required, ¤t); + assert!(matches!(availability, ActionAvailability::Available)); + } + + #[test] + fn test_context_missing() { + let required = RequiredContext::requires_target(); + let current = CurrentContext::default(); + + let availability = check_context_availability(&required, ¤t); + assert!(matches!( + availability, + ActionAvailability::Unavailable { missing_fields } if missing_fields == vec!["target"] + )); + } + + #[test] + fn test_context_promptable() { + let required = RequiredContext::requires_target_with_prompt(); + let current = CurrentContext { + can_prompt: true, + ..Default::default() + }; + + let availability = check_context_availability(&required, ¤t); + assert!(matches!( + availability, + ActionAvailability::AvailableWithPrompt { prompt_fields } if prompt_fields == vec!["target"] + )); + } +} diff --git a/crates/yaak-actions/src/error.rs b/crates/yaak-actions/src/error.rs new file mode 100644 index 00000000..1243f1c0 --- /dev/null +++ b/crates/yaak-actions/src/error.rs @@ -0,0 +1,131 @@ +//! Error types for the action system. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ts_rs::TS; + +use crate::{ActionGroupId, ActionId}; + +/// Errors that can occur during action operations. +#[derive(Debug, Error, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ActionError { + /// Action not found in registry. + #[error("Action not found: {0}")] + NotFound(ActionId), + + /// Action is disabled in current context. + #[error("Action is disabled: {action_id} - {reason}")] + Disabled { action_id: ActionId, reason: String }, + + /// Invalid scope for the action. + #[error("Invalid scope: expected {expected:?}, got {actual:?}")] + InvalidScope { + expected: crate::ActionScope, + actual: crate::ActionScope, + }, + + /// Action execution timed out. + #[error("Action timed out: {0}")] + Timeout(ActionId), + + /// Error from plugin execution. + #[error("Plugin error: {0}")] + PluginError(String), + + /// Validation error in action parameters. + #[error("Validation error: {0}")] + ValidationError(String), + + /// Permission denied for action. + #[error("Permission denied: {0}")] + PermissionDenied(String), + + /// Action was cancelled by user. + #[error("Action cancelled by user")] + Cancelled, + + /// Internal error. + #[error("Internal error: {0}")] + Internal(String), + + /// Required context is missing. + #[error("Required context missing: {missing_fields:?}")] + ContextMissing { + /// The context fields that are missing. + missing_fields: Vec, + }, + + /// Action group not found. + #[error("Group not found: {0}")] + GroupNotFound(ActionGroupId), + + /// Action group already exists. + #[error("Group already exists: {0}")] + GroupAlreadyExists(ActionGroupId), +} + +impl ActionError { + /// Get a user-friendly error message. + pub fn user_message(&self) -> String { + match self { + Self::NotFound(id) => format!("Action '{}' is not available", id), + Self::Disabled { reason, .. } => reason.clone(), + Self::InvalidScope { expected, actual } => { + format!("Action requires {:?} scope, but got {:?}", expected, actual) + } + Self::Timeout(_) => "The operation took too long and was cancelled".into(), + Self::PluginError(msg) => format!("Plugin error: {}", msg), + Self::ValidationError(msg) => format!("Invalid input: {}", msg), + Self::PermissionDenied(resource) => format!("Permission denied for {}", resource), + Self::Cancelled => "Operation was cancelled".into(), + Self::Internal(_) => "An unexpected error occurred".into(), + Self::ContextMissing { missing_fields } => { + format!("Missing required context: {}", missing_fields.join(", ")) + } + Self::GroupNotFound(id) => format!("Action group '{}' not found", id), + Self::GroupAlreadyExists(id) => format!("Action group '{}' already exists", id), + } + } + + /// Whether this error should be reported to telemetry. + pub fn is_reportable(&self) -> bool { + matches!(self, Self::Internal(_) | Self::PluginError(_)) + } + + /// Whether this error can potentially be resolved by user interaction. + pub fn is_promptable(&self) -> bool { + matches!(self, Self::ContextMissing { .. }) + } + + /// Whether this is a user-initiated cancellation. + pub fn is_cancelled(&self) -> bool { + matches!(self, Self::Cancelled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_messages() { + let err = ActionError::ContextMissing { + missing_fields: vec!["workspace".into()], + }; + assert_eq!(err.user_message(), "Missing required context: workspace"); + assert!(err.is_promptable()); + assert!(!err.is_cancelled()); + + let cancelled = ActionError::Cancelled; + assert!(cancelled.is_cancelled()); + assert!(!cancelled.is_promptable()); + + let not_found = ActionError::NotFound(ActionId::builtin("test", "action")); + assert_eq!( + not_found.user_message(), + "Action 'yaak:test:action' is not available" + ); + } +} diff --git a/crates/yaak-actions/src/executor.rs b/crates/yaak-actions/src/executor.rs new file mode 100644 index 00000000..a3c29418 --- /dev/null +++ b/crates/yaak-actions/src/executor.rs @@ -0,0 +1,606 @@ +//! Action executor - central hub for action registration and invocation. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::{ + check_context_availability, ActionAvailability, ActionError, ActionGroupId, + ActionGroupMetadata, ActionGroupSource, ActionGroupWithActions, ActionHandler, ActionId, + ActionMetadata, ActionParams, ActionResult, ActionScope, ActionSource, CurrentContext, + RegisteredActionGroup, +}; + +/// Options for listing actions. +#[derive(Clone, Debug, Default)] +pub struct ListActionsOptions { + /// Filter by scope. + pub scope: Option, + /// Filter by group. + pub group_id: Option, + /// Search term for label/description. + pub search: Option, +} + +/// A registered action with its handler. +struct RegisteredAction { + /// Action metadata. + metadata: ActionMetadata, + /// Where the action was registered from. + source: ActionSource, + /// The handler for this action. + handler: Arc, +} + +/// Central hub for action registration and invocation. +/// +/// The executor owns all action metadata and handlers, ensuring every +/// registered action has a handler by construction. +pub struct ActionExecutor { + /// All registered actions indexed by ID. + actions: RwLock>, + + /// Actions indexed by scope for efficient filtering. + scope_index: RwLock>>, + + /// All registered groups indexed by ID. + groups: RwLock>, +} + +impl Default for ActionExecutor { + fn default() -> Self { + Self::new() + } +} + +impl ActionExecutor { + /// Create a new empty executor. + pub fn new() -> Self { + Self { + actions: RwLock::new(HashMap::new()), + scope_index: RwLock::new(HashMap::new()), + groups: RwLock::new(HashMap::new()), + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Action Registration + // ───────────────────────────────────────────────────────────────────────── + + /// Register an action with its handler. + /// + /// Every action must have a handler - this is enforced by the API. + pub async fn register( + &self, + metadata: ActionMetadata, + source: ActionSource, + handler: H, + ) -> Result { + let id = metadata.id.clone(); + let scope = metadata.scope.clone(); + + let action = RegisteredAction { + metadata, + source, + handler: Arc::new(handler), + }; + + // Insert action + { + let mut actions = self.actions.write().await; + actions.insert(id.clone(), action); + } + + // Update scope index + { + let mut index = self.scope_index.write().await; + index.entry(scope).or_default().push(id.clone()); + } + + Ok(id) + } + + /// Unregister an action. + pub async fn unregister(&self, id: &ActionId) -> Result<(), ActionError> { + let mut actions = self.actions.write().await; + + let action = actions + .remove(id) + .ok_or_else(|| ActionError::NotFound(id.clone()))?; + + // Update scope index + { + let mut index = self.scope_index.write().await; + if let Some(ids) = index.get_mut(&action.metadata.scope) { + ids.retain(|i| i != id); + } + } + + // Remove from group if assigned + if let Some(group_id) = &action.metadata.group_id { + let mut groups = self.groups.write().await; + if let Some(group) = groups.get_mut(group_id) { + group.action_ids.retain(|i| i != id); + } + } + + Ok(()) + } + + /// Unregister all actions from a specific source. + pub async fn unregister_source(&self, source_id: &str) -> Vec { + let actions_to_remove: Vec = { + let actions = self.actions.read().await; + actions + .iter() + .filter(|(_, a)| match &a.source { + ActionSource::Plugin { ref_id, .. } => ref_id == source_id, + ActionSource::Dynamic { + source_id: sid, .. + } => sid == source_id, + ActionSource::Builtin => false, + }) + .map(|(id, _)| id.clone()) + .collect() + }; + + for id in &actions_to_remove { + let _ = self.unregister(id).await; + } + + actions_to_remove + } + + // ───────────────────────────────────────────────────────────────────────── + // Action Invocation + // ───────────────────────────────────────────────────────────────────────── + + /// Invoke an action with the given context and parameters. + /// + /// This will: + /// 1. Look up the action metadata + /// 2. Check context availability + /// 3. Execute the handler + pub async fn invoke( + &self, + action_id: &ActionId, + context: CurrentContext, + params: ActionParams, + ) -> Result { + // Get action and handler + let (metadata, handler) = { + let actions = self.actions.read().await; + let action = actions + .get(action_id) + .ok_or_else(|| ActionError::NotFound(action_id.clone()))?; + (action.metadata.clone(), action.handler.clone()) + }; + + // Check context availability + let availability = check_context_availability(&metadata.required_context, &context); + + match availability { + ActionAvailability::Available | ActionAvailability::AvailableWithPrompt { .. } => { + // Context is satisfied, proceed with execution + } + ActionAvailability::Unavailable { missing_fields } => { + return Err(ActionError::ContextMissing { missing_fields }); + } + ActionAvailability::NotFound => { + return Err(ActionError::NotFound(action_id.clone())); + } + } + + // Execute handler + handler.handle(context, params).await + } + + /// Invoke an action, skipping context validation. + /// + /// Use this when you've already validated the context externally. + pub async fn invoke_unchecked( + &self, + action_id: &ActionId, + context: CurrentContext, + params: ActionParams, + ) -> Result { + // Get handler + let handler = { + let actions = self.actions.read().await; + let action = actions + .get(action_id) + .ok_or_else(|| ActionError::NotFound(action_id.clone()))?; + action.handler.clone() + }; + + // Execute handler + handler.handle(context, params).await + } + + // ───────────────────────────────────────────────────────────────────────── + // Action Queries + // ───────────────────────────────────────────────────────────────────────── + + /// Get action metadata by ID. + pub async fn get(&self, id: &ActionId) -> Option { + let actions = self.actions.read().await; + actions.get(id).map(|a| a.metadata.clone()) + } + + /// List all actions, optionally filtered. + pub async fn list(&self, options: ListActionsOptions) -> Vec { + let actions = self.actions.read().await; + + let mut result: Vec<_> = actions + .values() + .filter(|a| { + // Scope filter + if let Some(scope) = &options.scope { + if &a.metadata.scope != scope { + return false; + } + } + + // Group filter + if let Some(group_id) = &options.group_id { + if a.metadata.group_id.as_ref() != Some(group_id) { + return false; + } + } + + // Search filter + if let Some(search) = &options.search { + let search = search.to_lowercase(); + let matches_label = a.metadata.label.to_lowercase().contains(&search); + let matches_desc = a + .metadata + .description + .as_ref() + .map(|d| d.to_lowercase().contains(&search)) + .unwrap_or(false); + if !matches_label && !matches_desc { + return false; + } + } + + true + }) + .map(|a| a.metadata.clone()) + .collect(); + + // Sort by order then label + result.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label))); + + result + } + + /// List actions available in the given context. + pub async fn list_available( + &self, + context: &CurrentContext, + options: ListActionsOptions, + ) -> Vec<(ActionMetadata, ActionAvailability)> { + let all_actions = self.list(options).await; + + all_actions + .into_iter() + .map(|action| { + let availability = + check_context_availability(&action.required_context, context); + (action, availability) + }) + .filter(|(_, availability)| availability.is_available()) + .collect() + } + + /// Get availability status for a specific action. + pub async fn get_availability( + &self, + id: &ActionId, + context: &CurrentContext, + ) -> ActionAvailability { + let actions = self.actions.read().await; + + match actions.get(id) { + Some(action) => { + check_context_availability(&action.metadata.required_context, context) + } + None => ActionAvailability::NotFound, + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Group Registration + // ───────────────────────────────────────────────────────────────────────── + + /// Register an action group. + pub async fn register_group( + &self, + metadata: ActionGroupMetadata, + source: ActionGroupSource, + ) -> Result { + let id = metadata.id.clone(); + + let mut groups = self.groups.write().await; + if groups.contains_key(&id) { + return Err(ActionError::GroupAlreadyExists(id)); + } + + groups.insert( + id.clone(), + RegisteredActionGroup { + metadata, + action_ids: Vec::new(), + source, + }, + ); + + Ok(id) + } + + /// Unregister a group (does not unregister its actions). + pub async fn unregister_group(&self, id: &ActionGroupId) -> Result<(), ActionError> { + let mut groups = self.groups.write().await; + groups + .remove(id) + .ok_or_else(|| ActionError::GroupNotFound(id.clone()))?; + Ok(()) + } + + /// Add an action to a group. + pub async fn add_to_group( + &self, + action_id: &ActionId, + group_id: &ActionGroupId, + ) -> Result<(), ActionError> { + // Update action's group_id + { + let mut actions = self.actions.write().await; + let action = actions + .get_mut(action_id) + .ok_or_else(|| ActionError::NotFound(action_id.clone()))?; + action.metadata.group_id = Some(group_id.clone()); + } + + // Add to group's action list + { + let mut groups = self.groups.write().await; + let group = groups + .get_mut(group_id) + .ok_or_else(|| ActionError::GroupNotFound(group_id.clone()))?; + + if !group.action_ids.contains(action_id) { + group.action_ids.push(action_id.clone()); + } + } + + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── + // Group Queries + // ───────────────────────────────────────────────────────────────────────── + + /// Get a group by ID. + pub async fn get_group(&self, id: &ActionGroupId) -> Option { + let groups = self.groups.read().await; + groups.get(id).map(|g| g.metadata.clone()) + } + + /// List all groups, optionally filtered by scope. + pub async fn list_groups(&self, scope: Option) -> Vec { + let groups = self.groups.read().await; + + let mut result: Vec<_> = groups + .values() + .filter(|g| { + scope.as_ref().map_or(true, |s| { + g.metadata.scope.as_ref().map_or(true, |gs| gs == s) + }) + }) + .map(|g| g.metadata.clone()) + .collect(); + + result.sort_by_key(|g| g.order); + result + } + + /// List all actions in a specific group. + pub async fn list_by_group(&self, group_id: &ActionGroupId) -> Vec { + let groups = self.groups.read().await; + let actions = self.actions.read().await; + + groups + .get(group_id) + .map(|group| { + let mut result: Vec<_> = group + .action_ids + .iter() + .filter_map(|id| actions.get(id).map(|a| a.metadata.clone())) + .collect(); + result.sort_by_key(|a| a.order); + result + }) + .unwrap_or_default() + } + + /// Get actions organized by their groups. + pub async fn list_grouped(&self, scope: Option) -> Vec { + let group_list = self.list_groups(scope).await; + let mut result = Vec::new(); + + for group in group_list { + let actions = self.list_by_group(&group.id).await; + result.push(ActionGroupWithActions { group, actions }); + } + + result + } + + // ───────────────────────────────────────────────────────────────────────── + // Built-in Registration + // ───────────────────────────────────────────────────────────────────────── + + /// Register all built-in groups. + pub async fn register_builtin_groups(&self) -> Result<(), ActionError> { + for group in crate::groups::builtin::all() { + self.register_group(group, ActionGroupSource::Builtin).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{handler_fn, RequiredContext}; + + async fn create_test_executor() -> ActionExecutor { + let executor = ActionExecutor::new(); + executor + .register( + ActionMetadata { + id: ActionId::builtin("test", "echo"), + label: "Echo".to_string(), + description: None, + icon: None, + scope: ActionScope::Global, + keyboard_shortcut: None, + requires_selection: false, + enabled_condition: None, + group_id: None, + order: 0, + required_context: RequiredContext::default(), + }, + ActionSource::Builtin, + handler_fn(|_ctx, params| async move { + let msg: String = params.get("message").unwrap_or_default(); + Ok(ActionResult::with_message(msg)) + }), + ) + .await + .unwrap(); + executor + } + + #[tokio::test] + async fn test_register_and_invoke() { + let executor = create_test_executor().await; + let action_id = ActionId::builtin("test", "echo"); + + let params = ActionParams::from_json(serde_json::json!({ + "message": "Hello, World!" + })); + + let result = executor + .invoke(&action_id, CurrentContext::default(), params) + .await + .unwrap(); + + match result { + ActionResult::Success { message, .. } => { + assert_eq!(message, Some("Hello, World!".to_string())); + } + _ => panic!("Expected Success result"), + } + } + + #[tokio::test] + async fn test_invoke_not_found() { + let executor = ActionExecutor::new(); + let action_id = ActionId::builtin("test", "unknown"); + + let result = executor + .invoke(&action_id, CurrentContext::default(), ActionParams::empty()) + .await; + + assert!(matches!(result, Err(ActionError::NotFound(_)))); + } + + #[tokio::test] + async fn test_list_by_scope() { + let executor = ActionExecutor::new(); + + executor + .register( + ActionMetadata { + id: ActionId::builtin("global", "one"), + label: "Global One".to_string(), + description: None, + icon: None, + scope: ActionScope::Global, + keyboard_shortcut: None, + requires_selection: false, + enabled_condition: None, + group_id: None, + order: 0, + required_context: RequiredContext::default(), + }, + ActionSource::Builtin, + handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }), + ) + .await + .unwrap(); + + executor + .register( + ActionMetadata { + id: ActionId::builtin("http", "one"), + label: "HTTP One".to_string(), + description: None, + icon: None, + scope: ActionScope::HttpRequest, + keyboard_shortcut: None, + requires_selection: false, + enabled_condition: None, + group_id: None, + order: 0, + required_context: RequiredContext::default(), + }, + ActionSource::Builtin, + handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }), + ) + .await + .unwrap(); + + let global_actions = executor + .list(ListActionsOptions { + scope: Some(ActionScope::Global), + ..Default::default() + }) + .await; + assert_eq!(global_actions.len(), 1); + + let http_actions = executor + .list(ListActionsOptions { + scope: Some(ActionScope::HttpRequest), + ..Default::default() + }) + .await; + assert_eq!(http_actions.len(), 1); + } + + #[tokio::test] + async fn test_groups() { + let executor = ActionExecutor::new(); + executor.register_builtin_groups().await.unwrap(); + + let groups = executor.list_groups(None).await; + assert!(!groups.is_empty()); + + let export_group = executor.get_group(&ActionGroupId::builtin("export")).await; + assert!(export_group.is_some()); + assert_eq!(export_group.unwrap().name, "Export"); + } + + #[tokio::test] + async fn test_unregister() { + let executor = create_test_executor().await; + let action_id = ActionId::builtin("test", "echo"); + + assert!(executor.get(&action_id).await.is_some()); + + executor.unregister(&action_id).await.unwrap(); + assert!(executor.get(&action_id).await.is_none()); + } +} diff --git a/crates/yaak-actions/src/groups.rs b/crates/yaak-actions/src/groups.rs new file mode 100644 index 00000000..e6fc10f2 --- /dev/null +++ b/crates/yaak-actions/src/groups.rs @@ -0,0 +1,208 @@ +//! Action group types and management. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{ActionId, ActionMetadata, ActionScope}; + +/// Unique identifier for an action group. +/// +/// Format: `namespace:group-name` +/// - Built-in: `yaak:export` +/// - Plugin: `plugin.my-plugin:utilities` +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ActionGroupId(pub String); + +impl ActionGroupId { + /// Create a namespaced group ID. + pub fn new(namespace: &str, name: &str) -> Self { + Self(format!("{}:{}", namespace, name)) + } + + /// Create ID for built-in groups. + pub fn builtin(name: &str) -> Self { + Self::new("yaak", name) + } + + /// Create ID for plugin groups. + pub fn plugin(plugin_ref_id: &str, name: &str) -> Self { + Self::new(&format!("plugin.{}", plugin_ref_id), name) + } + + /// Get the raw string value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ActionGroupId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Metadata about an action group. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ActionGroupMetadata { + /// Unique identifier for this group. + pub id: ActionGroupId, + + /// Display name for the group. + pub name: String, + + /// Optional description of the group's purpose. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Icon to display for the group. + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Sort order for displaying groups (lower = earlier). + #[serde(default)] + pub order: i32, + + /// Optional scope restriction (if set, group only appears in this scope). + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +/// Where an action group was registered from. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ActionGroupSource { + /// Built into Yaak core. + Builtin, + /// Registered by a plugin. + Plugin { + /// Plugin reference ID. + ref_id: String, + /// Plugin name. + name: String, + }, + /// Registered at runtime. + Dynamic { + /// Source identifier. + source_id: String, + }, +} + +/// A registered action group with its actions. +#[derive(Clone, Debug)] +pub struct RegisteredActionGroup { + /// Group metadata. + pub metadata: ActionGroupMetadata, + + /// IDs of actions in this group (ordered by action's order field). + pub action_ids: Vec, + + /// Where the group was registered from. + pub source: ActionGroupSource, +} + +/// A group with its actions for UI rendering. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ActionGroupWithActions { + /// Group metadata. + pub group: ActionGroupMetadata, + + /// Actions in this group. + pub actions: Vec, +} + +/// Built-in action group definitions. +pub mod builtin { + use super::*; + + /// Export group - export and copy actions. + pub fn export() -> ActionGroupMetadata { + ActionGroupMetadata { + id: ActionGroupId::builtin("export"), + name: "Export".into(), + description: Some("Export and copy actions".into()), + icon: Some("download".into()), + order: 100, + scope: None, + } + } + + /// Code generation group. + pub fn code_generation() -> ActionGroupMetadata { + ActionGroupMetadata { + id: ActionGroupId::builtin("code-generation"), + name: "Code Generation".into(), + description: Some("Generate code snippets from requests".into()), + icon: Some("code".into()), + order: 200, + scope: Some(ActionScope::HttpRequest), + } + } + + /// Send group - request sending actions. + pub fn send() -> ActionGroupMetadata { + ActionGroupMetadata { + id: ActionGroupId::builtin("send"), + name: "Send".into(), + description: Some("Actions for sending requests".into()), + icon: Some("play".into()), + order: 50, + scope: Some(ActionScope::HttpRequest), + } + } + + /// Import group. + pub fn import() -> ActionGroupMetadata { + ActionGroupMetadata { + id: ActionGroupId::builtin("import"), + name: "Import".into(), + description: Some("Import data from files".into()), + icon: Some("upload".into()), + order: 150, + scope: None, + } + } + + /// Workspace management group. + pub fn workspace() -> ActionGroupMetadata { + ActionGroupMetadata { + id: ActionGroupId::builtin("workspace"), + name: "Workspace".into(), + description: Some("Workspace management actions".into()), + icon: Some("folder".into()), + order: 300, + scope: Some(ActionScope::Workspace), + } + } + + /// Get all built-in group definitions. + pub fn all() -> Vec { + vec![send(), export(), import(), code_generation(), workspace()] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_group_id_creation() { + let id = ActionGroupId::builtin("export"); + assert_eq!(id.as_str(), "yaak:export"); + + let plugin_id = ActionGroupId::plugin("my-plugin", "utilities"); + assert_eq!(plugin_id.as_str(), "plugin.my-plugin:utilities"); + } + + #[test] + fn test_builtin_groups() { + let groups = builtin::all(); + assert!(!groups.is_empty()); + assert!(groups.iter().any(|g| g.id == ActionGroupId::builtin("export"))); + } +} diff --git a/crates/yaak-actions/src/handler.rs b/crates/yaak-actions/src/handler.rs new file mode 100644 index 00000000..0dd7deb8 --- /dev/null +++ b/crates/yaak-actions/src/handler.rs @@ -0,0 +1,103 @@ +//! Action handler types and execution. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::{ActionError, ActionParams, ActionResult, CurrentContext}; + +/// A boxed future for async action handlers. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Function signature for action handlers. +pub type ActionHandlerFn = Arc< + dyn Fn(CurrentContext, ActionParams) -> BoxFuture<'static, Result> + + Send + + Sync, +>; + +/// Trait for types that can handle action invocations. +pub trait ActionHandler: Send + Sync { + /// Execute the action with the given context and parameters. + fn handle( + &self, + context: CurrentContext, + params: ActionParams, + ) -> BoxFuture<'static, Result>; +} + +/// Wrapper to create an ActionHandler from a function. +pub struct FnHandler(pub F); + +impl ActionHandler for FnHandler +where + F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync, + Fut: Future> + Send + 'static, +{ + fn handle( + &self, + context: CurrentContext, + params: ActionParams, + ) -> BoxFuture<'static, Result> { + Box::pin((self.0)(context, params)) + } +} + +/// Create an action handler from an async function. +/// +/// # Example +/// ```ignore +/// let handler = handler_fn(|ctx, params| async move { +/// Ok(ActionResult::ok()) +/// }); +/// ``` +pub fn handler_fn(f: F) -> FnHandler +where + F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync, + Fut: Future> + Send + 'static, +{ + FnHandler(f) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_handler_fn() { + let handler = handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }); + + let result = handler + .handle(CurrentContext::default(), ActionParams::empty()) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handler_with_params() { + let handler = handler_fn(|_ctx, params| async move { + let name: Option = params.get("name"); + Ok(ActionResult::with_message(format!( + "Hello, {}!", + name.unwrap_or_else(|| "World".to_string()) + ))) + }); + + let params = ActionParams::from_json(serde_json::json!({ + "name": "Yaak" + })); + + let result = handler + .handle(CurrentContext::default(), params) + .await + .unwrap(); + + match result { + ActionResult::Success { message, .. } => { + assert_eq!(message, Some("Hello, Yaak!".to_string())); + } + _ => panic!("Expected Success result"), + } + } +} diff --git a/crates/yaak-actions/src/lib.rs b/crates/yaak-actions/src/lib.rs new file mode 100644 index 00000000..49678c87 --- /dev/null +++ b/crates/yaak-actions/src/lib.rs @@ -0,0 +1,18 @@ +//! Centralized action system for Yaak. +//! +//! This crate provides a unified hub for registering and invoking actions +//! across all entry points: plugins, Tauri desktop app, CLI, deep links, and MCP server. + +mod context; +mod error; +mod executor; +mod groups; +mod handler; +mod types; + +pub use context::*; +pub use error::*; +pub use executor::*; +pub use groups::*; +pub use handler::*; +pub use types::*; diff --git a/crates/yaak-actions/src/types.rs b/crates/yaak-actions/src/types.rs new file mode 100644 index 00000000..2c05f30e --- /dev/null +++ b/crates/yaak-actions/src/types.rs @@ -0,0 +1,273 @@ +//! Core types for the action system. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{ActionGroupId, RequiredContext}; + +/// Unique identifier for an action. +/// +/// Format: `namespace:category:name` +/// - Built-in: `yaak:http-request:send` +/// - Plugin: `plugin.copy-curl:http-request:copy` +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ActionId(pub String); + +impl ActionId { + /// Create a namespaced action ID. + pub fn new(namespace: &str, category: &str, name: &str) -> Self { + Self(format!("{}:{}:{}", namespace, category, name)) + } + + /// Create ID for built-in actions. + pub fn builtin(category: &str, name: &str) -> Self { + Self::new("yaak", category, name) + } + + /// Create ID for plugin actions. + pub fn plugin(plugin_ref_id: &str, category: &str, name: &str) -> Self { + Self::new(&format!("plugin.{}", plugin_ref_id), category, name) + } + + /// Get the raw string value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ActionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The scope in which an action can be invoked. +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum ActionScope { + /// Global actions available everywhere. + Global, + /// Actions on HTTP requests. + HttpRequest, + /// Actions on WebSocket requests. + WebsocketRequest, + /// Actions on gRPC requests. + GrpcRequest, + /// Actions on workspaces. + Workspace, + /// Actions on folders. + Folder, + /// Actions on environments. + Environment, + /// Actions on cookie jars. + CookieJar, +} + +/// Metadata about an action for discovery. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ActionMetadata { + /// Unique identifier for this action. + pub id: ActionId, + + /// Display label for the action. + pub label: String, + + /// Optional description of what the action does. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Icon name to display. + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// The scope this action applies to. + pub scope: ActionScope, + + /// Keyboard shortcut (e.g., "Cmd+Enter"). + #[serde(skip_serializing_if = "Option::is_none")] + pub keyboard_shortcut: Option, + + /// Whether the action requires a selection/target. + #[serde(default)] + pub requires_selection: bool, + + /// Optional condition expression for when action is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_condition: Option, + + /// Optional group this action belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + + /// Sort order within a group (lower = earlier). + #[serde(default)] + pub order: i32, + + /// Context requirements for this action. + #[serde(default)] + pub required_context: RequiredContext, +} + +/// Where an action was registered from. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ActionSource { + /// Built into Yaak core. + Builtin, + /// Registered by a plugin. + Plugin { + /// Plugin reference ID. + ref_id: String, + /// Plugin name. + name: String, + }, + /// Registered at runtime (e.g., by MCP tools). + Dynamic { + /// Source identifier. + source_id: String, + }, +} + +/// Parameters passed to action handlers. +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ActionParams { + /// Arbitrary JSON parameters. + #[serde(default)] + #[ts(type = "unknown")] + pub data: serde_json::Value, +} + +impl ActionParams { + /// Create empty params. + pub fn empty() -> Self { + Self { + data: serde_json::Value::Null, + } + } + + /// Create params from a JSON value. + pub fn from_json(data: serde_json::Value) -> Self { + Self { data } + } + + /// Get a typed value from the params. + pub fn get(&self, key: &str) -> Option { + self.data + .get(key) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + } +} + +/// Result of action execution. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ActionResult { + /// Action completed successfully. + Success { + /// Optional data to return. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(type = "unknown")] + data: Option, + /// Optional message to display. + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + + /// Action requires user input to continue. + RequiresInput { + /// Prompt to show user. + prompt: InputPrompt, + /// Continuation token. + continuation_id: String, + }, + + /// Action was cancelled by the user. + Cancelled, +} + +impl ActionResult { + /// Create a success result with no data. + pub fn ok() -> Self { + Self::Success { + data: None, + message: None, + } + } + + /// Create a success result with a message. + pub fn with_message(message: impl Into) -> Self { + Self::Success { + data: None, + message: Some(message.into()), + } + } + + /// Create a success result with data. + pub fn with_data(data: serde_json::Value) -> Self { + Self::Success { + data: Some(data), + message: None, + } + } +} + +/// A prompt for user input. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum InputPrompt { + /// Text input prompt. + Text { + label: String, + placeholder: Option, + default_value: Option, + }, + /// Selection prompt. + Select { + label: String, + options: Vec, + }, + /// Confirmation prompt. + Confirm { label: String }, +} + +/// An option in a select prompt. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct SelectOption { + pub label: String, + pub value: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_action_id_creation() { + let id = ActionId::builtin("http-request", "send"); + assert_eq!(id.as_str(), "yaak:http-request:send"); + + let plugin_id = ActionId::plugin("copy-curl", "http-request", "copy"); + assert_eq!(plugin_id.as_str(), "plugin.copy-curl:http-request:copy"); + } + + #[test] + fn test_action_params() { + let params = ActionParams::from_json(serde_json::json!({ + "name": "test", + "count": 42 + })); + + assert_eq!(params.get::("name"), Some("test".to_string())); + assert_eq!(params.get::("count"), Some(42)); + assert_eq!(params.get::("missing"), None); + } +}