Skip to main content

max / balanced_breakfast

14.0 KB · 463 lines History Blame Raw
1 //! Busser trait and plugin interface
2 //!
3 //! This module defines the interface that Rhai plugins must implement.
4 //! Plugins are simple .rhai text files that define specific functions.
5
6 use serde::{Deserialize, Serialize};
7
8 use crate::{BusserError, FetchResult};
9
10 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11 /// Type of configuration field
12 pub enum ConfigFieldType {
13 /// Single line text input
14 Text,
15 /// Multi-line text input
16 TextArea,
17 /// Password/secret (masked input)
18 Secret,
19 /// URL input
20 Url,
21 /// Number input
22 Number,
23 /// Boolean toggle
24 Toggle,
25 /// Select from options
26 Select,
27 }
28
29 #[derive(Clone, Debug, Serialize, Deserialize)]
30 /// A configuration field that a busser requires
31 pub struct ConfigField {
32 /// Field key (used in BusserConfig)
33 pub key: String,
34 /// Display label
35 pub label: String,
36 /// Help text / description
37 pub description: Option<String>,
38 /// Field type
39 pub field_type: ConfigFieldType,
40 /// Whether this field is required
41 pub required: bool,
42 /// Default value
43 pub default: Option<String>,
44 /// Options for Select type
45 pub options: Vec<String>,
46 /// Placeholder text
47 pub placeholder: Option<String>,
48 }
49
50 impl ConfigField {
51 /// Create a single-line text field.
52 pub fn text(key: impl Into<String>, label: impl Into<String>) -> Self {
53 Self {
54 key: key.into(),
55 label: label.into(),
56 description: None,
57 field_type: ConfigFieldType::Text,
58 required: false,
59 default: None,
60 options: Vec::new(),
61 placeholder: None,
62 }
63 }
64
65 /// Create a URL input field.
66 pub fn url(key: impl Into<String>, label: impl Into<String>) -> Self {
67 Self {
68 key: key.into(),
69 label: label.into(),
70 description: None,
71 field_type: ConfigFieldType::Url,
72 required: false,
73 default: None,
74 options: Vec::new(),
75 placeholder: None,
76 }
77 }
78
79 /// Create a masked secret/password field.
80 pub fn secret(key: impl Into<String>, label: impl Into<String>) -> Self {
81 Self {
82 key: key.into(),
83 label: label.into(),
84 description: None,
85 field_type: ConfigFieldType::Secret,
86 required: false,
87 default: None,
88 options: Vec::new(),
89 placeholder: None,
90 }
91 }
92
93 /// Create a dropdown select field with predefined options.
94 pub fn select(key: impl Into<String>, label: impl Into<String>, options: Vec<String>) -> Self {
95 Self {
96 key: key.into(),
97 label: label.into(),
98 description: None,
99 field_type: ConfigFieldType::Select,
100 required: false,
101 default: None,
102 options,
103 placeholder: None,
104 }
105 }
106
107 /// Mark this field as required.
108 pub fn required(mut self) -> Self {
109 self.required = true;
110 self
111 }
112
113 /// Set the help text shown below the field.
114 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
115 self.description = Some(desc.into());
116 self
117 }
118
119 /// Set a default value for the field.
120 pub fn with_default(mut self, default: impl Into<String>) -> Self {
121 self.default = Some(default.into());
122 self
123 }
124
125 /// Set placeholder text shown when the field is empty.
126 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
127 self.placeholder = Some(placeholder.into());
128 self
129 }
130 }
131
132 #[derive(Clone, Debug, Serialize, Deserialize)]
133 /// Configuration schema returned by a busser
134 pub struct ConfigSchema {
135 /// Human-readable description of the busser
136 pub description: String,
137 /// Configuration fields
138 pub fields: Vec<ConfigField>,
139 }
140
141 impl ConfigSchema {
142 /// Create a new schema with a human-readable description.
143 pub fn new(description: impl Into<String>) -> Self {
144 Self {
145 description: description.into(),
146 fields: Vec::new(),
147 }
148 }
149
150 /// Add a configuration field to the schema.
151 pub fn field(mut self, field: ConfigField) -> Self {
152 self.fields.push(field);
153 self
154 }
155 }
156
157 /// Default auto-fetch interval: 15 minutes.
158 pub const DEFAULT_FETCH_INTERVAL_SECS: u64 = 900;
159
160 #[derive(Clone, Debug, Serialize, Deserialize)]
161 /// Capabilities a busser can advertise
162 pub struct BusserCapabilities {
163 /// Supports pagination
164 pub supports_pagination: bool,
165 /// Supports searching
166 pub supports_search: bool,
167 /// Supports filtering by date
168 pub supports_date_filter: bool,
169 /// Supports marking items as read
170 pub supports_read_state: bool,
171 /// Supports starring/favoriting
172 pub supports_starring: bool,
173 /// Requires authentication
174 pub requires_auth: bool,
175 /// Preferred auto-fetch interval in seconds (0 = no auto-fetch)
176 pub fetch_interval_secs: u64,
177 }
178
179 impl Default for BusserCapabilities {
180 fn default() -> Self {
181 Self {
182 supports_pagination: false,
183 supports_search: false,
184 supports_date_filter: false,
185 supports_read_state: false,
186 supports_starring: false,
187 requires_auth: false,
188 fetch_interval_secs: DEFAULT_FETCH_INTERVAL_SECS,
189 }
190 }
191 }
192
193 impl BusserCapabilities {
194 /// Create default capabilities (all features disabled).
195 pub fn new() -> Self {
196 Self::default()
197 }
198
199 /// Advertise pagination support.
200 pub fn with_pagination(mut self) -> Self {
201 self.supports_pagination = true;
202 self
203 }
204
205 /// Advertise search support.
206 pub fn with_search(mut self) -> Self {
207 self.supports_search = true;
208 self
209 }
210
211 /// Advertise date filtering support.
212 pub fn with_date_filter(mut self) -> Self {
213 self.supports_date_filter = true;
214 self
215 }
216
217 /// Advertise read-state tracking support.
218 pub fn with_read_state(mut self) -> Self {
219 self.supports_read_state = true;
220 self
221 }
222
223 /// Advertise starring/favouriting support.
224 pub fn with_starring(mut self) -> Self {
225 self.supports_starring = true;
226 self
227 }
228
229 /// Indicate that authentication is required.
230 pub fn requiring_auth(mut self) -> Self {
231 self.requires_auth = true;
232 self
233 }
234 }
235
236 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
237 /// Configuration passed to a busser during initialization
238 pub struct BusserConfig {
239 /// Key-value configuration options
240 pub options: std::collections::HashMap<String, String>,
241 /// Feed URLs or identifiers to fetch
242 pub feeds: Vec<String>,
243 }
244
245 impl BusserConfig {
246 /// Create an empty busser configuration.
247 pub fn new() -> Self {
248 Self::default()
249 }
250
251 /// Add a key-value option to the configuration.
252 pub fn add_option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
253 self.options.insert(key.into(), value.into());
254 self
255 }
256
257 /// Add a feed URL or identifier to fetch.
258 pub fn add_feed(mut self, feed: impl Into<String>) -> Self {
259 self.feeds.push(feed.into());
260 self
261 }
262
263 /// Look up an option value by key.
264 pub fn get(&self, key: &str) -> Option<&str> {
265 self.options.get(key).map(|s| s.as_str())
266 }
267 }
268
269 /// The main trait that all bussers must implement.
270 ///
271 /// Note: Rhai plugins implement this interface via functions, not this trait directly.
272 /// The trait is kept for documentation and potential Rust-native plugin support.
273 pub trait Busser: Send + Sync {
274 /// Unique identifier for this busser type
275 fn id(&self) -> String;
276
277 /// Human-readable name
278 fn name(&self) -> String;
279
280 /// Advertised capabilities
281 fn capabilities(&self) -> BusserCapabilities;
282
283 /// Get the configuration schema for this busser
284 fn config_schema(&self) -> ConfigSchema;
285
286 /// Initialize the busser with configuration
287 fn initialize(&mut self, config: BusserConfig) -> Result<(), BusserError>;
288
289 /// Fetch items, optionally from a cursor position
290 fn fetch(&self, cursor: Option<String>) -> Result<FetchResult, BusserError>;
291
292 /// Clean shutdown
293 fn shutdown(&mut self) -> Result<(), BusserError>;
294 }
295
296 #[cfg(test)]
297 mod tests {
298 use super::*;
299
300 // ── BusserConfig ─────────────────────────────────────────────
301
302 #[test]
303 fn busser_config_new_is_empty() {
304 let config = BusserConfig::new();
305 assert!(config.options.is_empty());
306 assert!(config.feeds.is_empty());
307 }
308
309 #[test]
310 fn busser_config_builder_chain() {
311 let config = BusserConfig::new()
312 .add_option("api_key", "secret123")
313 .add_feed("https://example.com/feed.xml")
314 .add_feed("https://other.com/rss");
315 assert_eq!(config.get("api_key"), Some("secret123"));
316 assert_eq!(config.feeds.len(), 2);
317 }
318
319 #[test]
320 fn busser_config_get_missing_returns_none() {
321 let config = BusserConfig::new();
322 assert!(config.get("nonexistent").is_none());
323 }
324
325 // ── ConfigFieldType ──────────────────────────────────────────
326
327 #[test]
328 fn config_field_type_variants() {
329 // Ensure all variants exist and are distinct
330 let types = [
331 ConfigFieldType::Text,
332 ConfigFieldType::TextArea,
333 ConfigFieldType::Secret,
334 ConfigFieldType::Url,
335 ConfigFieldType::Number,
336 ConfigFieldType::Toggle,
337 ConfigFieldType::Select,
338 ];
339 for (i, a) in types.iter().enumerate() {
340 for (j, b) in types.iter().enumerate() {
341 if i == j {
342 assert_eq!(a, b);
343 } else {
344 assert_ne!(a, b);
345 }
346 }
347 }
348 }
349
350 // ── ConfigField constructors ─────────────────────────────────
351
352 #[test]
353 fn config_field_text_creates_text_field() {
354 let field = ConfigField::text("api_key", "API Key");
355 assert_eq!(field.key, "api_key");
356 assert_eq!(field.label, "API Key");
357 assert_eq!(field.field_type, ConfigFieldType::Text);
358 assert!(!field.required);
359 assert!(field.description.is_none());
360 assert!(field.default.is_none());
361 assert!(field.options.is_empty());
362 assert!(field.placeholder.is_none());
363 }
364
365 #[test]
366 fn config_field_url_creates_url_field() {
367 let field = ConfigField::url("feed_url", "Feed URL");
368 assert_eq!(field.key, "feed_url");
369 assert_eq!(field.field_type, ConfigFieldType::Url);
370 }
371
372 #[test]
373 fn config_field_secret_creates_secret_field() {
374 let field = ConfigField::secret("token", "API Token");
375 assert_eq!(field.key, "token");
376 assert_eq!(field.field_type, ConfigFieldType::Secret);
377 }
378
379 #[test]
380 fn config_field_select_has_options() {
381 let opts = vec!["newest".into(), "oldest".into(), "popular".into()];
382 let field = ConfigField::select("sort", "Sort By", opts);
383 assert_eq!(field.field_type, ConfigFieldType::Select);
384 assert_eq!(field.options.len(), 3);
385 assert_eq!(field.options[0], "newest");
386 }
387
388 #[test]
389 fn config_field_builder_chain() {
390 let field = ConfigField::text("name", "Name")
391 .required()
392 .with_description("Enter your name")
393 .with_default("John")
394 .with_placeholder("e.g. Jane");
395 assert!(field.required);
396 assert_eq!(field.description.as_deref(), Some("Enter your name"));
397 assert_eq!(field.default.as_deref(), Some("John"));
398 assert_eq!(field.placeholder.as_deref(), Some("e.g. Jane"));
399 }
400
401 // ── ConfigSchema ─────────────────────────────────────────────
402
403 #[test]
404 fn config_schema_new_is_empty() {
405 let schema = ConfigSchema::new("Test Busser");
406 assert_eq!(schema.description, "Test Busser");
407 assert!(schema.fields.is_empty());
408 }
409
410 #[test]
411 fn config_schema_field_chain() {
412 let schema = ConfigSchema::new("RSS Reader")
413 .field(ConfigField::url("feed_url", "Feed URL").required())
414 .field(ConfigField::secret("api_key", "API Key"));
415 assert_eq!(schema.fields.len(), 2);
416 assert_eq!(schema.fields[0].key, "feed_url");
417 assert!(schema.fields[0].required);
418 assert_eq!(schema.fields[1].key, "api_key");
419 }
420
421 // ── BusserCapabilities ───────────────────────────────────────
422
423 #[test]
424 fn busser_capabilities_defaults_all_false() {
425 let caps = BusserCapabilities::new();
426 assert!(!caps.supports_pagination);
427 assert!(!caps.supports_search);
428 assert!(!caps.supports_date_filter);
429 assert!(!caps.supports_read_state);
430 assert!(!caps.supports_starring);
431 assert!(!caps.requires_auth);
432 assert_eq!(caps.fetch_interval_secs, DEFAULT_FETCH_INTERVAL_SECS);
433 }
434
435 #[test]
436 fn busser_capabilities_with_pagination() {
437 let caps = BusserCapabilities::new().with_pagination();
438 assert!(caps.supports_pagination);
439 assert!(!caps.supports_search);
440 }
441
442 #[test]
443 fn busser_capabilities_with_date_filter() {
444 let caps = BusserCapabilities::new().with_date_filter();
445 assert!(caps.supports_date_filter);
446 }
447
448 #[test]
449 fn busser_capabilities_builder_chain() {
450 let caps = BusserCapabilities::new()
451 .with_search()
452 .with_read_state()
453 .with_starring()
454 .requiring_auth();
455 assert!(!caps.supports_pagination);
456 assert!(caps.supports_search);
457 assert!(!caps.supports_date_filter);
458 assert!(caps.supports_read_state);
459 assert!(caps.supports_starring);
460 assert!(caps.requires_auth);
461 }
462 }
463