//! Busser trait and plugin interface //! //! This module defines the interface that Rhai plugins must implement. //! Plugins are simple .rhai text files that define specific functions. use serde::{Deserialize, Serialize}; use crate::{BusserError, FetchResult}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] /// Type of configuration field pub enum ConfigFieldType { /// Single line text input Text, /// Multi-line text input TextArea, /// Password/secret (masked input) Secret, /// URL input Url, /// Number input Number, /// Boolean toggle Toggle, /// Select from options Select, } #[derive(Clone, Debug, Serialize, Deserialize)] /// A configuration field that a busser requires pub struct ConfigField { /// Field key (used in BusserConfig) pub key: String, /// Display label pub label: String, /// Help text / description pub description: Option, /// Field type pub field_type: ConfigFieldType, /// Whether this field is required pub required: bool, /// Default value pub default: Option, /// Options for Select type pub options: Vec, /// Placeholder text pub placeholder: Option, } impl ConfigField { /// Create a single-line text field. pub fn text(key: impl Into, label: impl Into) -> Self { Self { key: key.into(), label: label.into(), description: None, field_type: ConfigFieldType::Text, required: false, default: None, options: Vec::new(), placeholder: None, } } /// Create a URL input field. pub fn url(key: impl Into, label: impl Into) -> Self { Self { key: key.into(), label: label.into(), description: None, field_type: ConfigFieldType::Url, required: false, default: None, options: Vec::new(), placeholder: None, } } /// Create a masked secret/password field. pub fn secret(key: impl Into, label: impl Into) -> Self { Self { key: key.into(), label: label.into(), description: None, field_type: ConfigFieldType::Secret, required: false, default: None, options: Vec::new(), placeholder: None, } } /// Create a dropdown select field with predefined options. pub fn select(key: impl Into, label: impl Into, options: Vec) -> Self { Self { key: key.into(), label: label.into(), description: None, field_type: ConfigFieldType::Select, required: false, default: None, options, placeholder: None, } } /// Mark this field as required. pub fn required(mut self) -> Self { self.required = true; self } /// Set the help text shown below the field. pub fn with_description(mut self, desc: impl Into) -> Self { self.description = Some(desc.into()); self } /// Set a default value for the field. pub fn with_default(mut self, default: impl Into) -> Self { self.default = Some(default.into()); self } /// Set placeholder text shown when the field is empty. pub fn with_placeholder(mut self, placeholder: impl Into) -> Self { self.placeholder = Some(placeholder.into()); self } } #[derive(Clone, Debug, Serialize, Deserialize)] /// Configuration schema returned by a busser pub struct ConfigSchema { /// Human-readable description of the busser pub description: String, /// Configuration fields pub fields: Vec, } impl ConfigSchema { /// Create a new schema with a human-readable description. pub fn new(description: impl Into) -> Self { Self { description: description.into(), fields: Vec::new(), } } /// Add a configuration field to the schema. pub fn field(mut self, field: ConfigField) -> Self { self.fields.push(field); self } } /// Default auto-fetch interval: 15 minutes. pub const DEFAULT_FETCH_INTERVAL_SECS: u64 = 900; #[derive(Clone, Debug, Serialize, Deserialize)] /// Capabilities a busser can advertise pub struct BusserCapabilities { /// Supports pagination pub supports_pagination: bool, /// Supports searching pub supports_search: bool, /// Supports filtering by date pub supports_date_filter: bool, /// Supports marking items as read pub supports_read_state: bool, /// Supports starring/favoriting pub supports_starring: bool, /// Requires authentication pub requires_auth: bool, /// Preferred auto-fetch interval in seconds (0 = no auto-fetch) pub fetch_interval_secs: u64, } impl Default for BusserCapabilities { fn default() -> Self { Self { supports_pagination: false, supports_search: false, supports_date_filter: false, supports_read_state: false, supports_starring: false, requires_auth: false, fetch_interval_secs: DEFAULT_FETCH_INTERVAL_SECS, } } } impl BusserCapabilities { /// Create default capabilities (all features disabled). pub fn new() -> Self { Self::default() } /// Advertise pagination support. pub fn with_pagination(mut self) -> Self { self.supports_pagination = true; self } /// Advertise search support. pub fn with_search(mut self) -> Self { self.supports_search = true; self } /// Advertise date filtering support. pub fn with_date_filter(mut self) -> Self { self.supports_date_filter = true; self } /// Advertise read-state tracking support. pub fn with_read_state(mut self) -> Self { self.supports_read_state = true; self } /// Advertise starring/favouriting support. pub fn with_starring(mut self) -> Self { self.supports_starring = true; self } /// Indicate that authentication is required. pub fn requiring_auth(mut self) -> Self { self.requires_auth = true; self } } #[derive(Clone, Debug, Default, Serialize, Deserialize)] /// Configuration passed to a busser during initialization pub struct BusserConfig { /// Key-value configuration options pub options: std::collections::HashMap, /// Feed URLs or identifiers to fetch pub feeds: Vec, } impl BusserConfig { /// Create an empty busser configuration. pub fn new() -> Self { Self::default() } /// Add a key-value option to the configuration. pub fn add_option(mut self, key: impl Into, value: impl Into) -> Self { self.options.insert(key.into(), value.into()); self } /// Add a feed URL or identifier to fetch. pub fn add_feed(mut self, feed: impl Into) -> Self { self.feeds.push(feed.into()); self } /// Look up an option value by key. pub fn get(&self, key: &str) -> Option<&str> { self.options.get(key).map(|s| s.as_str()) } } /// The main trait that all bussers must implement. /// /// Note: Rhai plugins implement this interface via functions, not this trait directly. /// The trait is kept for documentation and potential Rust-native plugin support. pub trait Busser: Send + Sync { /// Unique identifier for this busser type fn id(&self) -> String; /// Human-readable name fn name(&self) -> String; /// Advertised capabilities fn capabilities(&self) -> BusserCapabilities; /// Get the configuration schema for this busser fn config_schema(&self) -> ConfigSchema; /// Initialize the busser with configuration fn initialize(&mut self, config: BusserConfig) -> Result<(), BusserError>; /// Fetch items, optionally from a cursor position fn fetch(&self, cursor: Option) -> Result; /// Clean shutdown fn shutdown(&mut self) -> Result<(), BusserError>; } #[cfg(test)] mod tests { use super::*; // ── BusserConfig ───────────────────────────────────────────── #[test] fn busser_config_new_is_empty() { let config = BusserConfig::new(); assert!(config.options.is_empty()); assert!(config.feeds.is_empty()); } #[test] fn busser_config_builder_chain() { let config = BusserConfig::new() .add_option("api_key", "secret123") .add_feed("https://example.com/feed.xml") .add_feed("https://other.com/rss"); assert_eq!(config.get("api_key"), Some("secret123")); assert_eq!(config.feeds.len(), 2); } #[test] fn busser_config_get_missing_returns_none() { let config = BusserConfig::new(); assert!(config.get("nonexistent").is_none()); } // ── ConfigFieldType ────────────────────────────────────────── #[test] fn config_field_type_variants() { // Ensure all variants exist and are distinct let types = [ ConfigFieldType::Text, ConfigFieldType::TextArea, ConfigFieldType::Secret, ConfigFieldType::Url, ConfigFieldType::Number, ConfigFieldType::Toggle, ConfigFieldType::Select, ]; for (i, a) in types.iter().enumerate() { for (j, b) in types.iter().enumerate() { if i == j { assert_eq!(a, b); } else { assert_ne!(a, b); } } } } // ── ConfigField constructors ───────────────────────────────── #[test] fn config_field_text_creates_text_field() { let field = ConfigField::text("api_key", "API Key"); assert_eq!(field.key, "api_key"); assert_eq!(field.label, "API Key"); assert_eq!(field.field_type, ConfigFieldType::Text); assert!(!field.required); assert!(field.description.is_none()); assert!(field.default.is_none()); assert!(field.options.is_empty()); assert!(field.placeholder.is_none()); } #[test] fn config_field_url_creates_url_field() { let field = ConfigField::url("feed_url", "Feed URL"); assert_eq!(field.key, "feed_url"); assert_eq!(field.field_type, ConfigFieldType::Url); } #[test] fn config_field_secret_creates_secret_field() { let field = ConfigField::secret("token", "API Token"); assert_eq!(field.key, "token"); assert_eq!(field.field_type, ConfigFieldType::Secret); } #[test] fn config_field_select_has_options() { let opts = vec!["newest".into(), "oldest".into(), "popular".into()]; let field = ConfigField::select("sort", "Sort By", opts); assert_eq!(field.field_type, ConfigFieldType::Select); assert_eq!(field.options.len(), 3); assert_eq!(field.options[0], "newest"); } #[test] fn config_field_builder_chain() { let field = ConfigField::text("name", "Name") .required() .with_description("Enter your name") .with_default("John") .with_placeholder("e.g. Jane"); assert!(field.required); assert_eq!(field.description.as_deref(), Some("Enter your name")); assert_eq!(field.default.as_deref(), Some("John")); assert_eq!(field.placeholder.as_deref(), Some("e.g. Jane")); } // ── ConfigSchema ───────────────────────────────────────────── #[test] fn config_schema_new_is_empty() { let schema = ConfigSchema::new("Test Busser"); assert_eq!(schema.description, "Test Busser"); assert!(schema.fields.is_empty()); } #[test] fn config_schema_field_chain() { let schema = ConfigSchema::new("RSS Reader") .field(ConfigField::url("feed_url", "Feed URL").required()) .field(ConfigField::secret("api_key", "API Key")); assert_eq!(schema.fields.len(), 2); assert_eq!(schema.fields[0].key, "feed_url"); assert!(schema.fields[0].required); assert_eq!(schema.fields[1].key, "api_key"); } // ── BusserCapabilities ─────────────────────────────────────── #[test] fn busser_capabilities_defaults_all_false() { let caps = BusserCapabilities::new(); assert!(!caps.supports_pagination); assert!(!caps.supports_search); assert!(!caps.supports_date_filter); assert!(!caps.supports_read_state); assert!(!caps.supports_starring); assert!(!caps.requires_auth); assert_eq!(caps.fetch_interval_secs, DEFAULT_FETCH_INTERVAL_SECS); } #[test] fn busser_capabilities_with_pagination() { let caps = BusserCapabilities::new().with_pagination(); assert!(caps.supports_pagination); assert!(!caps.supports_search); } #[test] fn busser_capabilities_with_date_filter() { let caps = BusserCapabilities::new().with_date_filter(); assert!(caps.supports_date_filter); } #[test] fn busser_capabilities_builder_chain() { let caps = BusserCapabilities::new() .with_search() .with_read_state() .with_starring() .requiring_auth(); assert!(!caps.supports_pagination); assert!(caps.supports_search); assert!(!caps.supports_date_filter); assert!(caps.supports_read_state); assert!(caps.supports_starring); assert!(caps.requires_auth); } }