Skip to main content

max / balanced_breakfast

Split rhai_plugin conversions.rs into json/feed_parse/domain modules Break the 1380-line conversions.rs into a conversions/ directory of three clusters behind a conversions/mod.rs facade: json.rs (json_to_dynamic, parse_json_feed), feed_parse.rs (XML/RSS/Atom parsing plus its private helpers), and domain.rs (the Dynamic-to-domain mappers). mod.rs keeps the module doc and the fetch() return contract, carries the shared named imports, re-exports each cluster via pub(crate) use, and retains the test module. The nine fns imported by host_functions.rs and mod.rs are widened from pub(super) to pub(crate) so the facade can re-export them, leaving every caller's use path unchanged. warn! is re-imported in domain.rs since macros do not cross use super::*. No behavior change; fn multiset identical, build clean, clippy unchanged, 153 bb-core tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 16:21 UTC
Commit: 75a179577fb9f40b34bfd51275279c95b771bdb3
Parent: 5484fa0
5 files changed, +1210 insertions, -500 deletions
@@ -1,1380 +0,0 @@
1 - //! Rhai <-> Rust type conversions: JSON, XML feed parsing, and Dynamic-to-domain mapping.
2 - //!
3 - //! # Plugin `fetch()` return contract
4 - //!
5 - //! A Rhai plugin's `fetch(config, cursor)` function must return a map with:
6 - //!
7 - //! - **`items`** (array, required) -- array of item maps, each containing:
8 - //! - `id` (string or `{source, item_id}` map, required)
9 - //! - `bite` (map, optional) -- `{author, text, secondary?, indicator?}`
10 - //! - `content` (map, optional) -- `{title?, body?, url?, media?}`
11 - //! - `meta` (map, optional) -- `{source_name?, published_at?, score?, tags?}`
12 - //! - **`has_more`** (bool, optional, default `false`) -- signals additional pages.
13 - //! - **`next_cursor`** (string, optional) -- opaque cursor passed to the next
14 - //! `fetch()` call for pagination.
15 - //!
16 - //! Missing optional fields fall back to sensible defaults (empty strings,
17 - //! current timestamp, etc.). See [`dynamic_to_fetch_result`] and
18 - //! [`dynamic_to_feed_item`] for the full conversion logic.
19 -
20 - use bb_interface::{
21 - BiteDisplay, BusserCapabilities, BusserConfig, ConfigField, ConfigFieldType, ConfigSchema,
22 - FeedItem, FeedItemContent, FeedItemId, FeedItemMeta, FetchResult, ItemAction,
23 - };
24 - use rhai::{Dynamic, Map};
25 - use tracing::warn;
26 -
27 - use super::RhaiPluginError;
28 -
29 - /// Convert serde_json::Value to Rhai Dynamic.
30 - pub(super) fn json_to_dynamic(val: serde_json::Value) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
31 - match val {
32 - serde_json::Value::Null => Ok(Dynamic::UNIT),
33 - serde_json::Value::Bool(b) => Ok(b.into()),
34 - serde_json::Value::Number(n) => {
35 - if let Some(i) = n.as_i64() {
36 - Ok(i.into())
37 - } else if let Some(f) = n.as_f64() {
38 - Ok(f.into())
39 - } else {
40 - Ok(n.to_string().into())
41 - }
42 - }
43 - serde_json::Value::String(s) => Ok(s.into()),
44 - serde_json::Value::Array(arr) => {
45 - let rhai_arr: Result<rhai::Array, _> = arr.into_iter().map(json_to_dynamic).collect();
46 - Ok(rhai_arr?.into())
47 - }
48 - serde_json::Value::Object(obj) => {
49 - let mut map = Map::new();
50 - for (k, v) in obj {
51 - map.insert(k.into(), json_to_dynamic(v)?);
52 - }
53 - Ok(map.into())
54 - }
55 - }
56 - }
57 -
58 - /// Parse XML into a simplified Dynamic structure.
59 - pub(super) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
60 - let doc =
61 - roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
62 -
63 - fn element_to_dynamic(node: roxmltree::Node) -> Dynamic {
64 - let mut map = Map::new();
65 -
66 - // Tag name
67 - if let Some(tag) = node.tag_name().name().into() {
68 - map.insert("tag".into(), Dynamic::from(tag.to_string()));
69 - }
70 -
71 - // Text content
72 - let text: String = node
73 - .children()
74 - .filter(|n| n.is_text())
75 - .map(|n| n.text().unwrap_or(""))
76 - .collect();
77 - if !text.trim().is_empty() {
78 - map.insert("text".into(), Dynamic::from(text.trim().to_string()));
79 - }
80 -
81 - // Attributes
82 - let mut attrs = Map::new();
83 - for attr in node.attributes() {
84 - attrs.insert(attr.name().into(), Dynamic::from(attr.value().to_string()));
85 - }
86 - if !attrs.is_empty() {
87 - map.insert("attrs".into(), Dynamic::from(attrs));
88 - }
89 -
90 - // Children
91 - let children: rhai::Array = node
92 - .children()
93 - .filter(|n| n.is_element())
94 - .map(element_to_dynamic)
95 - .collect();
96 - if !children.is_empty() {
97 - map.insert("children".into(), Dynamic::from(children));
98 - }
99 -
100 - Dynamic::from(map)
101 - }
102 -
103 - Ok(element_to_dynamic(doc.root_element()))
104 - }
105 -
106 - /// Parse RSS/Atom feed XML into a feed-friendly structure.
107 - pub(super) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
108 - let doc =
109 - roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
110 - let root = doc.root_element();
111 -
112 - let mut feed = Map::new();
113 - let mut entries: rhai::Array = Vec::new();
114 -
115 - // Detect feed type
116 - let is_atom = root.tag_name().name() == "feed";
117 - let is_rss = root.tag_name().name() == "rss" || root.tag_name().name() == "RDF";
118 -
119 - if is_atom {
120 - for child in root.children().filter(|n| n.is_element()) {
121 - match child.tag_name().name() {
122 - "title" => {
123 - feed.insert("title".into(), get_text_content(&child).into());
124 - }
125 - "link" => {
126 - if let Some(href) = child.attribute("href") {
127 - feed.insert("link".into(), href.to_string().into());
128 - }
129 - }
130 - "entry" => {
131 - entries.push(parse_atom_entry(&child));
132 - }
133 - _ => {}
134 - }
135 - }
136 - } else if is_rss {
137 - let channel = root
138 - .children()
139 - .find(|n| n.is_element() && n.tag_name().name() == "channel");
140 -
141 - if let Some(channel) = channel {
142 - for child in channel.children().filter(|n| n.is_element()) {
143 - match child.tag_name().name() {
144 - "title" => {
145 - feed.insert("title".into(), get_text_content(&child).into());
146 - }
147 - "link" => {
148 - feed.insert("link".into(), get_text_content(&child).into());
149 - }
150 - "item" => {
151 - entries.push(parse_rss_item(&child));
152 - }
153 - _ => {}
154 - }
155 - }
156 - }
157 - }
158 -
159 - feed.insert("entries".into(), entries.into());
160 - Ok(feed.into())
161 - }
162 -
163 - fn get_text_content(node: &roxmltree::Node) -> String {
164 - node.text().unwrap_or("").trim().to_string()
165 - }
166 -
167 - fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic {
168 - let mut entry = Map::new();
169 -
170 - for child in node.children().filter(|n| n.is_element()) {
171 - match child.tag_name().name() {
172 - "id" => {
173 - entry.insert("id".into(), get_text_content(&child).into());
174 - }
175 - "title" => {
176 - entry.insert("title".into(), get_text_content(&child).into());
177 - }
178 - "summary" | "content" => {
179 - if !entry.contains_key("summary") {
180 - entry.insert("summary".into(), get_text_content(&child).into());
181 - }
182 - }
183 - "link" => {
184 - if let Some(href) = child.attribute("href") {
185 - entry.insert("link".into(), href.to_string().into());
186 - }
187 - }
188 - "published" | "updated" => {
189 - if !entry.contains_key("published") {
190 - let date_str = get_text_content(&child);
191 - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
192 - entry.insert("published".into(), dt.timestamp().into());
193 - }
194 - }
195 - }
196 - "author" => {
197 - if let Some(name_node) =
198 - child.children().find(|n| n.tag_name().name() == "name")
199 - {
200 - entry.insert("author".into(), get_text_content(&name_node).into());
201 - }
202 - }
203 - "category" => {
204 - if let Some(term) = child.attribute("term") {
205 - let tags_arr = entry
206 - .get("tags")
207 - .and_then(|existing| existing.clone().try_cast::<rhai::Array>())
208 - .unwrap_or_default();
209 - let mut tags_arr = tags_arr;
210 - tags_arr.push(term.to_string().into());
211 - entry.insert("tags".into(), tags_arr.into());
212 - }
213 - }
214 - _ => {}
215 - }
216 - }
217 -
218 - entry.into()
219 - }
220 -
221 - fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
222 - let mut item = Map::new();
223 -
224 - for child in node.children().filter(|n| n.is_element()) {
225 - match child.tag_name().name() {
226 - "guid" => {
227 - item.insert("id".into(), get_text_content(&child).into());
228 - }
229 - "title" => {
230 - item.insert("title".into(), get_text_content(&child).into());
231 - }
232 - "description" => {
233 - item.insert("summary".into(), get_text_content(&child).into());
234 - }
235 - "link" => {
236 - item.insert("link".into(), get_text_content(&child).into());
237 - }
238 - "pubDate" => {
239 - let date_str = get_text_content(&child);
240 - if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(&date_str) {
241 - item.insert("published".into(), dt.timestamp().into());
242 - }
243 - }
244 - "author" | "creator" => {
245 - item.insert("author".into(), get_text_content(&child).into());
246 - }
247 - "category" => {
248 - let tags_arr = item
249 - .get("tags")
250 - .and_then(|existing| existing.clone().try_cast::<rhai::Array>())
251 - .unwrap_or_default();
252 - let mut tags_arr = tags_arr;
253 - tags_arr.push(get_text_content(&child).into());
254 - item.insert("tags".into(), tags_arr.into());
255 - }
256 - _ => {}
257 - }
258 - }
259 -
260 - // If no guid, use link as id; if no link either, synthesize from title + pubDate hash
261 - if !item.contains_key("id") {
262 - if let Some(link) = item.get("link") {
263 - item.insert("id".into(), link.clone());
264 - } else {
265 - // Synthesize a deterministic ID from available fields so the same
266 - // item always gets the same ID across fetches.
267 - let title = item
268 - .get("title")
269 - .and_then(|v| v.clone().try_cast::<String>())
270 - .unwrap_or_default();
271 - let summary = item
272 - .get("summary")
273 - .and_then(|v| v.clone().try_cast::<String>())
274 - .unwrap_or_default();
275 - let published = item
276 - .get("published")
277 - .map(|v| v.to_string())
278 - .unwrap_or_default();
279 -
280 - use std::hash::{Hash, Hasher};
281 - let mut hasher = std::collections::hash_map::DefaultHasher::new();
282 - title.hash(&mut hasher);
283 - summary.hash(&mut hasher);
284 - published.hash(&mut hasher);
285 - let hash = hasher.finish();
286 - item.insert("id".into(), Dynamic::from(format!("synth-{:016x}", hash)));
287 - }
288 - }
289 -
290 - item.into()
291 - }
292 -
293 - /// Parse a JSON Feed (1.0/1.1) string into the same structure as `parse_feed_xml`:
294 - /// `{ title, link, entries: [{ id, title, summary, link, published, author, tags }] }`
295 - ///
296 - /// JSON Feed spec: <https://www.jsonfeed.org/version/1.1/>
297 - pub(super) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
298 - let json: serde_json::Value =
299 - serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
300 -
301 - let obj = json
302 - .as_object()
303 - .ok_or("JSON Feed root must be an object")?;
304 -
305 - // Validate it's a JSON Feed by checking the version field
306 - let version = obj
307 - .get("version")
308 - .and_then(|v| v.as_str())
309 - .unwrap_or("");
310 - if !version.starts_with("https://jsonfeed.org/version/") {
311 - return Err(format!("Not a valid JSON Feed: version = {:?}", version).into());
312 - }
313 -
314 - let mut feed = Map::new();
315 -
316 - if let Some(title) = obj.get("title").and_then(|v| v.as_str()) {
317 - feed.insert("title".into(), title.to_string().into());
318 - }
319 - if let Some(link) = obj.get("home_page_url").and_then(|v| v.as_str()) {
320 - feed.insert("link".into(), link.to_string().into());
321 - }
322 -
323 - let mut entries: rhai::Array = Vec::new();
324 -
325 - if let Some(items) = obj.get("items").and_then(|v| v.as_array()) {
326 - for item in items {
327 - if let Some(item_obj) = item.as_object() {
328 - let mut entry = Map::new();
329 -
330 - if let Some(id) = item_obj.get("id").and_then(|v| v.as_str()) {
331 - entry.insert("id".into(), id.to_string().into());
332 - }
333 - if let Some(title) = item_obj.get("title").and_then(|v| v.as_str()) {
334 - entry.insert("title".into(), title.to_string().into());
335 - }
336 - if let Some(url) = item_obj.get("url").and_then(|v| v.as_str()) {
337 - entry.insert("link".into(), url.to_string().into());
338 - }
339 -
340 - // Prefer content_html over content_text for body/summary
341 - let body = item_obj
342 - .get("content_html")
343 - .and_then(|v| v.as_str())
344 - .or_else(|| item_obj.get("content_text").and_then(|v| v.as_str()))
345 - .or_else(|| item_obj.get("summary").and_then(|v| v.as_str()));
346 - if let Some(body) = body {
347 - entry.insert("summary".into(), body.to_string().into());
348 - }
349 -
350 - // Published date
351 - if let Some(date_str) = item_obj
352 - .get("date_published")
353 - .and_then(|v| v.as_str())
354 - {
355 - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
356 - entry.insert("published".into(), dt.timestamp().into());
357 - }
358 - }
359 -
360 - // Author: JSON Feed 1.1 uses "authors" array, 1.0 uses "author" object
361 - let author_name = item_obj
362 - .get("authors")
363 - .and_then(|v| v.as_array())
364 - .and_then(|arr| arr.first())
365 - .and_then(|a| a.get("name"))
366 - .and_then(|v| v.as_str())
367 - .or_else(|| {
368 - item_obj
369 - .get("author")
370 - .and_then(|v| v.get("name"))
371 - .and_then(|v| v.as_str())
372 - });
373 - if let Some(name) = author_name {
374 - entry.insert("author".into(), name.to_string().into());
375 - }
376 -
377 - // Tags
378 - if let Some(tags) = item_obj.get("tags").and_then(|v| v.as_array()) {
379 - let tags_arr: rhai::Array = tags
380 - .iter()
381 - .filter_map(|t| t.as_str().map(|s| Dynamic::from(s.to_string())))
382 - .collect();
383 - if !tags_arr.is_empty() {
384 - entry.insert("tags".into(), tags_arr.into());
385 - }
386 - }
387 -
388 - entries.push(entry.into());
389 - }
390 - }
391 - }
392 -
393 - feed.insert("entries".into(), entries.into());
394 - Ok(feed.into())
395 - }
396 -
397 - /// Convert BusserConfig to Rhai Dynamic map.
398 - pub(super) fn busser_config_to_dynamic(config: &BusserConfig) -> Dynamic {
399 - let mut map = Map::new();
400 -
401 - for (k, v) in &config.options {
402 - map.insert(k.clone().into(), v.clone().into());
403 - }
404 -
405 - let feeds: rhai::Array = config.feeds.iter().map(|f| f.clone().into()).collect();
406 - map.insert("feeds".into(), feeds.into());
407 -
408 - if let Some(first_feed) = config.feeds.first() {
409 - map.insert("feed_url".into(), first_feed.clone().into());
410 - }
411 -
412 - map.into()
413 - }
414 -
415 - /// Convert Dynamic to ConfigSchema.
416 - pub(super) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, RhaiPluginError> {
417 - let map = val.try_cast::<Map>().ok_or_else(|| {
418 - RhaiPluginError::InvalidReturnType(
419 - "config_schema".into(),
420 - "expected object/map".into(),
421 - )
422 - })?;
423 -
424 - let description = map
425 - .get("description")
426 - .and_then(|v| v.clone().try_cast::<String>())
427 - .unwrap_or_default();
428 -
429 - let mut schema = ConfigSchema::new(description);
430 -
431 - if let Some(fields_val) = map.get("fields") {
432 - if let Some(fields) = fields_val.clone().try_cast::<rhai::Array>() {
433 - for field_val in fields {
434 - if let Some(field_map) = field_val.try_cast::<Map>() {
435 - let key = field_map
436 - .get("key")
437 - .and_then(|v| v.clone().try_cast::<String>())
438 - .unwrap_or_default();
439 -
440 - let label = field_map
441 - .get("label")
442 - .and_then(|v| v.clone().try_cast::<String>())
443 - .unwrap_or_default();
444 -
445 - let field_type_str = field_map
446 - .get("field_type")
447 - .and_then(|v| v.clone().try_cast::<String>())
448 - .unwrap_or_else(|| "text".to_string());
449 -
450 - let field_type = match field_type_str.to_lowercase().as_str() {
451 - "url" => ConfigFieldType::Url,
452 - "secret" => ConfigFieldType::Secret,
453 - "textarea" => ConfigFieldType::TextArea,
454 - "number" => ConfigFieldType::Number,
455 - "toggle" => ConfigFieldType::Toggle,
456 - "select" => ConfigFieldType::Select,
457 - _ => ConfigFieldType::Text,
458 - };
459 -
460 - let required = field_map
461 - .get("required")
462 - .and_then(|v| v.clone().try_cast::<bool>())
463 - .unwrap_or(false);
464 -
465 - // Try both "default" and "default_value" since "default" is reserved in Rhai
466 - let default_val = field_map
467 - .get("default_value")
468 - .or_else(|| field_map.get("default"))
469 - .and_then(|v| v.clone().try_cast::<String>());
470 -
471 - let mut field = ConfigField {
472 - key,
473 - label,
474 - field_type,
475 - required,
476 - description: field_map
477 - .get("description")
478 - .and_then(|v| v.clone().try_cast::<String>()),
479 - default: default_val,
480 - placeholder: field_map
481 - .get("placeholder")
482 - .and_then(|v| v.clone().try_cast::<String>()),
483 - options: Vec::new(),
484 - };
485 -
486 - if let Some(opts_val) = field_map.get("options") {
487 - if let Some(opts) = opts_val.clone().try_cast::<rhai::Array>() {
488 - field.options = opts
489 - .into_iter()
490 - .filter_map(|v| v.try_cast::<String>())
491 - .collect();
492 - }
493 - }
494 -
495 - schema.fields.push(field);
496 - }
497 - }
498 - }
499 - }
500 -
Lines truncated
@@ -0,0 +1,340 @@
1 + use super::*;
2 + use tracing::warn;
3 +
4 + /// Convert BusserConfig to Rhai Dynamic map.
5 + pub(crate) fn busser_config_to_dynamic(config: &BusserConfig) -> Dynamic {
6 + let mut map = Map::new();
7 +
8 + for (k, v) in &config.options {
9 + map.insert(k.clone().into(), v.clone().into());
10 + }
11 +
12 + let feeds: rhai::Array = config.feeds.iter().map(|f| f.clone().into()).collect();
13 + map.insert("feeds".into(), feeds.into());
14 +
15 + if let Some(first_feed) = config.feeds.first() {
16 + map.insert("feed_url".into(), first_feed.clone().into());
17 + }
18 +
19 + map.into()
20 + }
21 +
22 + /// Convert Dynamic to ConfigSchema.
23 + pub(crate) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, RhaiPluginError> {
24 + let map = val.try_cast::<Map>().ok_or_else(|| {
25 + RhaiPluginError::InvalidReturnType(
26 + "config_schema".into(),
27 + "expected object/map".into(),
28 + )
29 + })?;
30 +
31 + let description = map
32 + .get("description")
33 + .and_then(|v| v.clone().try_cast::<String>())
34 + .unwrap_or_default();
35 +
36 + let mut schema = ConfigSchema::new(description);
37 +
38 + if let Some(fields_val) = map.get("fields") {
39 + if let Some(fields) = fields_val.clone().try_cast::<rhai::Array>() {
40 + for field_val in fields {
41 + if let Some(field_map) = field_val.try_cast::<Map>() {
42 + let key = field_map
43 + .get("key")
44 + .and_then(|v| v.clone().try_cast::<String>())
45 + .unwrap_or_default();
46 +
47 + let label = field_map
48 + .get("label")
49 + .and_then(|v| v.clone().try_cast::<String>())
50 + .unwrap_or_default();
51 +
52 + let field_type_str = field_map
53 + .get("field_type")
54 + .and_then(|v| v.clone().try_cast::<String>())
55 + .unwrap_or_else(|| "text".to_string());
56 +
57 + let field_type = match field_type_str.to_lowercase().as_str() {
58 + "url" => ConfigFieldType::Url,
59 + "secret" => ConfigFieldType::Secret,
60 + "textarea" => ConfigFieldType::TextArea,
61 + "number" => ConfigFieldType::Number,
62 + "toggle" => ConfigFieldType::Toggle,
63 + "select" => ConfigFieldType::Select,
64 + _ => ConfigFieldType::Text,
65 + };
66 +
67 + let required = field_map
68 + .get("required")
69 + .and_then(|v| v.clone().try_cast::<bool>())
70 + .unwrap_or(false);
71 +
72 + // Try both "default" and "default_value" since "default" is reserved in Rhai
73 + let default_val = field_map
74 + .get("default_value")
75 + .or_else(|| field_map.get("default"))
76 + .and_then(|v| v.clone().try_cast::<String>());
77 +
78 + let mut field = ConfigField {
79 + key,
80 + label,
81 + field_type,
82 + required,
83 + description: field_map
84 + .get("description")
85 + .and_then(|v| v.clone().try_cast::<String>()),
86 + default: default_val,
87 + placeholder: field_map
88 + .get("placeholder")
89 + .and_then(|v| v.clone().try_cast::<String>()),
90 + options: Vec::new(),
91 + };
92 +
93 + if let Some(opts_val) = field_map.get("options") {
94 + if let Some(opts) = opts_val.clone().try_cast::<rhai::Array>() {
95 + field.options = opts
96 + .into_iter()
97 + .filter_map(|v| v.try_cast::<String>())
98 + .collect();
99 + }
100 + }
101 +
102 + schema.fields.push(field);
103 + }
104 + }
105 + }
106 + }
107 +
108 + Ok(schema)
109 + }
110 +
111 + /// Convert Dynamic to BusserCapabilities.
112 + pub(crate) fn dynamic_to_capabilities(val: Dynamic) -> BusserCapabilities {
113 + let map = match val.try_cast::<Map>() {
114 + Some(m) => m,
115 + None => return BusserCapabilities::default(),
116 + };
117 +
118 + let defaults = BusserCapabilities::default();
119 +
120 + BusserCapabilities {
121 + supports_pagination: map
122 + .get("supports_pagination")
123 + .and_then(|v| v.clone().try_cast::<bool>())
124 + .unwrap_or(false),
125 + supports_search: map
126 + .get("supports_search")
127 + .and_then(|v| v.clone().try_cast::<bool>())
128 + .unwrap_or(false),
129 + supports_date_filter: map
130 + .get("supports_date_filter")
131 + .and_then(|v| v.clone().try_cast::<bool>())
132 + .unwrap_or(false),
133 + supports_read_state: map
134 + .get("supports_read_state")
135 + .and_then(|v| v.clone().try_cast::<bool>())
136 + .unwrap_or(false),
137 + supports_starring: map
138 + .get("supports_starring")
139 + .and_then(|v| v.clone().try_cast::<bool>())
140 + .unwrap_or(false),
141 + requires_auth: map
142 + .get("requires_auth")
143 + .and_then(|v| v.clone().try_cast::<bool>())
144 + .unwrap_or(false),
145 + fetch_interval_secs: map
146 + .get("fetch_interval_secs")
147 + .and_then(|v| v.clone().try_cast::<i64>())
148 + .map(|v| v.max(0) as u64)
149 + .unwrap_or(defaults.fetch_interval_secs),
150 + }
151 + }
152 +
153 + /// Convert Dynamic to FetchResult.
154 + pub(crate) fn dynamic_to_fetch_result(
155 + val: Dynamic,
156 + source_id: &str,
157 + ) -> Result<FetchResult, RhaiPluginError> {
158 + let map = val.try_cast::<Map>().ok_or_else(|| {
159 + RhaiPluginError::InvalidReturnType("fetch".into(), "expected object/map".into())
160 + })?;
161 +
162 + let items_val = map.get("items").ok_or_else(|| {
163 + RhaiPluginError::InvalidReturnType("fetch".into(), "missing 'items' field".into())
164 + })?;
165 +
166 + let items_arr = items_val.clone().try_cast::<rhai::Array>().ok_or_else(|| {
167 + RhaiPluginError::InvalidReturnType("fetch".into(), "'items' must be an array".into())
168 + })?;
169 +
170 + let mut items = Vec::new();
171 + for item_val in items_arr {
172 + match dynamic_to_feed_item(item_val, source_id) {
173 + Ok(item) => items.push(item),
174 + Err(e) => {
175 + warn!(error = %e, "Failed to parse feed item");
176 + }
177 + }
178 + }
179 +
180 + let has_more = map
181 + .get("has_more")
182 + .and_then(|v| v.clone().try_cast::<bool>())
183 + .unwrap_or(false);
184 +
185 + let next_cursor = map
186 + .get("next_cursor")
187 + .and_then(|v| v.clone().try_cast::<String>());
188 +
189 + let mut result = FetchResult::new(items);
190 + result.has_more = has_more;
191 + result.next_cursor = next_cursor;
192 +
193 + Ok(result)
194 + }
195 +
196 + /// Convert Dynamic to FeedItem.
197 + pub(crate) fn dynamic_to_feed_item(
198 + val: Dynamic,
199 + source_id: &str,
200 + ) -> Result<FeedItem, RhaiPluginError> {
201 + let map = val.try_cast::<Map>().ok_or_else(|| {
202 + RhaiPluginError::InvalidReturnType("item".into(), "expected object/map".into())
203 + })?;
204 +
205 + // Parse ID
206 + let id = if let Some(id_val) = map.get("id") {
207 + if let Some(id_map) = id_val.clone().try_cast::<Map>() {
208 + let source = id_map
209 + .get("source")
210 + .and_then(|v| v.clone().try_cast::<String>())
211 + .unwrap_or_else(|| source_id.to_string());
212 + let item_id = id_map
213 + .get("item_id")
214 + .and_then(|v| v.clone().try_cast::<String>())
215 + .unwrap_or_default();
216 + FeedItemId::new(source, item_id)
217 + } else if let Some(id_str) = id_val.clone().try_cast::<String>() {
218 + FeedItemId::new(source_id, id_str)
219 + } else {
220 + return Err(RhaiPluginError::InvalidReturnType(
221 + "item.id".into(),
222 + "expected string or {source, item_id}".into(),
223 + ));
224 + }
225 + } else {
226 + return Err(RhaiPluginError::InvalidReturnType(
227 + "item".into(),
228 + "missing 'id' field".into(),
229 + ));
230 + };
231 +
232 + // Parse bite display
233 + let bite = if let Some(bite_val) = map.get("bite") {
234 + if let Some(bite_map) = bite_val.clone().try_cast::<Map>() {
235 + let author = bite_map
236 + .get("author")
237 + .and_then(|v| v.clone().try_cast::<String>())
238 + .unwrap_or_default();
239 + let text = bite_map
240 + .get("text")
241 + .and_then(|v| v.clone().try_cast::<String>())
242 + .unwrap_or_default();
243 +
244 + let mut bite = BiteDisplay::new(author, text);
245 + bite.secondary = bite_map
246 + .get("secondary")
247 + .and_then(|v| v.clone().try_cast::<String>());
248 + bite.indicator = bite_map
249 + .get("indicator")
250 + .and_then(|v| v.clone().try_cast::<String>());
251 + bite
252 + } else {
253 + BiteDisplay::new("", "")
254 + }
255 + } else {
256 + BiteDisplay::new("", "")
257 + };
258 +
259 + // Parse content
260 + let content = if let Some(content_val) = map.get("content") {
261 + if let Some(content_map) = content_val.clone().try_cast::<Map>() {
262 + let mut content = FeedItemContent::new();
263 + content.title = content_map
264 + .get("title")
265 + .and_then(|v| v.clone().try_cast::<String>());
266 + content.body = content_map
267 + .get("body")
268 + .and_then(|v| v.clone().try_cast::<String>());
269 + content.url = content_map
270 + .get("url")
271 + .and_then(|v| v.clone().try_cast::<String>());
272 +
273 + if let Some(media_val) = content_map.get("media") {
274 + if let Some(media_arr) = media_val.clone().try_cast::<rhai::Array>() {
275 + content.media = media_arr
276 + .into_iter()
277 + .filter_map(|v| v.try_cast::<String>())
278 + .collect();
279 + }
280 + }
281 +
282 + if let Some(actions_val) = content_map.get("actions") {
283 + if let Some(actions_arr) = actions_val.clone().try_cast::<rhai::Array>() {
284 + content.actions = actions_arr
285 + .into_iter()
286 + .filter_map(|v| {
287 + let m = v.try_cast::<Map>()?;
288 + Some(ItemAction {
289 + label: m.get("label")?.clone().try_cast::<String>()?,
290 + action_type: m.get("action_type")?.clone().try_cast::<String>()?,
291 + url: m.get("url")?.clone().try_cast::<String>()?,
292 + })
293 + })
294 + .collect();
295 + }
296 + }
297 + content
298 + } else {
299 + FeedItemContent::new()
300 + }
301 + } else {
302 + FeedItemContent::new()
303 + };
304 +
305 + // Parse meta
306 + let meta = if let Some(meta_val) = map.get("meta") {
307 + if let Some(meta_map) = meta_val.clone().try_cast::<Map>() {
308 + let source_name = meta_map
309 + .get("source_name")
310 + .and_then(|v| v.clone().try_cast::<String>())
311 + .unwrap_or_else(|| source_id.to_string());
312 +
313 + let published_at = meta_map
314 + .get("published_at")
315 + .and_then(|v| v.clone().try_cast::<i64>())
316 + .unwrap_or_else(|| chrono::Utc::now().timestamp());
317 +
318 + let mut meta = FeedItemMeta::new(source_name, published_at);
319 + meta.score = meta_map
320 + .get("score")
321 + .and_then(|v| v.clone().try_cast::<i64>());
322 +
323 + if let Some(tags_val) = meta_map.get("tags") {
324 + if let Some(tags_arr) = tags_val.clone().try_cast::<rhai::Array>() {
325 + meta.tags = tags_arr
326 + .into_iter()
327 + .filter_map(|v| v.try_cast::<String>())
328 + .collect();
329 + }
330 + }
331 + meta
332 + } else {
333 + FeedItemMeta::new(source_id, chrono::Utc::now().timestamp())
334 + }
335 + } else {
336 + FeedItemMeta::new(source_id, chrono::Utc::now().timestamp())
337 + };
338 +
339 + Ok(FeedItem::new(id, bite, content, meta))
340 + }
@@ -0,0 +1,236 @@
1 + use super::*;
2 +
3 + /// Parse XML into a simplified Dynamic structure.
4 + pub(crate) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
5 + let doc =
6 + roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
7 +
8 + fn element_to_dynamic(node: roxmltree::Node) -> Dynamic {
9 + let mut map = Map::new();
10 +
11 + // Tag name
12 + if let Some(tag) = node.tag_name().name().into() {
13 + map.insert("tag".into(), Dynamic::from(tag.to_string()));
14 + }
15 +
16 + // Text content
17 + let text: String = node
18 + .children()
19 + .filter(|n| n.is_text())
20 + .map(|n| n.text().unwrap_or(""))
21 + .collect();
22 + if !text.trim().is_empty() {
23 + map.insert("text".into(), Dynamic::from(text.trim().to_string()));
24 + }
25 +
26 + // Attributes
27 + let mut attrs = Map::new();
28 + for attr in node.attributes() {
29 + attrs.insert(attr.name().into(), Dynamic::from(attr.value().to_string()));
30 + }
31 + if !attrs.is_empty() {
32 + map.insert("attrs".into(), Dynamic::from(attrs));
33 + }
34 +
35 + // Children
36 + let children: rhai::Array = node
37 + .children()
38 + .filter(|n| n.is_element())
39 + .map(element_to_dynamic)
40 + .collect();
41 + if !children.is_empty() {
42 + map.insert("children".into(), Dynamic::from(children));
43 + }
44 +
45 + Dynamic::from(map)
46 + }
47 +
48 + Ok(element_to_dynamic(doc.root_element()))
49 + }
50 +
51 + /// Parse RSS/Atom feed XML into a feed-friendly structure.
52 + pub(crate) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
53 + let doc =
54 + roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
55 + let root = doc.root_element();
56 +
57 + let mut feed = Map::new();
58 + let mut entries: rhai::Array = Vec::new();
59 +
60 + // Detect feed type
61 + let is_atom = root.tag_name().name() == "feed";
62 + let is_rss = root.tag_name().name() == "rss" || root.tag_name().name() == "RDF";
63 +
64 + if is_atom {
65 + for child in root.children().filter(|n| n.is_element()) {
66 + match child.tag_name().name() {
67 + "title" => {
68 + feed.insert("title".into(), get_text_content(&child).into());
69 + }
70 + "link" => {
71 + if let Some(href) = child.attribute("href") {
72 + feed.insert("link".into(), href.to_string().into());
73 + }
74 + }
75 + "entry" => {
76 + entries.push(parse_atom_entry(&child));
77 + }
78 + _ => {}
79 + }
80 + }
81 + } else if is_rss {
82 + let channel = root
83 + .children()
84 + .find(|n| n.is_element() && n.tag_name().name() == "channel");
85 +
86 + if let Some(channel) = channel {
87 + for child in channel.children().filter(|n| n.is_element()) {
88 + match child.tag_name().name() {
89 + "title" => {
90 + feed.insert("title".into(), get_text_content(&child).into());
91 + }
92 + "link" => {
93 + feed.insert("link".into(), get_text_content(&child).into());
94 + }
95 + "item" => {
96 + entries.push(parse_rss_item(&child));
97 + }
98 + _ => {}
99 + }
100 + }
101 + }
102 + }
103 +
104 + feed.insert("entries".into(), entries.into());
105 + Ok(feed.into())
106 + }
107 +
108 + fn get_text_content(node: &roxmltree::Node) -> String {
109 + node.text().unwrap_or("").trim().to_string()
110 + }
111 +
112 + fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic {
113 + let mut entry = Map::new();
114 +
115 + for child in node.children().filter(|n| n.is_element()) {
116 + match child.tag_name().name() {
117 + "id" => {
118 + entry.insert("id".into(), get_text_content(&child).into());
119 + }
120 + "title" => {
121 + entry.insert("title".into(), get_text_content(&child).into());
122 + }
123 + "summary" | "content" => {
124 + if !entry.contains_key("summary") {
125 + entry.insert("summary".into(), get_text_content(&child).into());
126 + }
127 + }
128 + "link" => {
129 + if let Some(href) = child.attribute("href") {
130 + entry.insert("link".into(), href.to_string().into());
131 + }
132 + }
133 + "published" | "updated" => {
134 + if !entry.contains_key("published") {
135 + let date_str = get_text_content(&child);
136 + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
137 + entry.insert("published".into(), dt.timestamp().into());
138 + }
139 + }
140 + }
141 + "author" => {
142 + if let Some(name_node) =
143 + child.children().find(|n| n.tag_name().name() == "name")
144 + {
145 + entry.insert("author".into(), get_text_content(&name_node).into());
146 + }
147 + }
148 + "category" => {
149 + if let Some(term) = child.attribute("term") {
150 + let tags_arr = entry
151 + .get("tags")
152 + .and_then(|existing| existing.clone().try_cast::<rhai::Array>())
153 + .unwrap_or_default();
154 + let mut tags_arr = tags_arr;
155 + tags_arr.push(term.to_string().into());
156 + entry.insert("tags".into(), tags_arr.into());
157 + }
158 + }
159 + _ => {}
160 + }
161 + }
162 +
163 + entry.into()
164 + }
165 +
166 + fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
167 + let mut item = Map::new();
168 +
169 + for child in node.children().filter(|n| n.is_element()) {
170 + match child.tag_name().name() {
171 + "guid" => {
172 + item.insert("id".into(), get_text_content(&child).into());
173 + }
174 + "title" => {
175 + item.insert("title".into(), get_text_content(&child).into());
176 + }
177 + "description" => {
178 + item.insert("summary".into(), get_text_content(&child).into());
179 + }
180 + "link" => {
181 + item.insert("link".into(), get_text_content(&child).into());
182 + }
183 + "pubDate" => {
184 + let date_str = get_text_content(&child);
185 + if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(&date_str) {
186 + item.insert("published".into(), dt.timestamp().into());
187 + }
188 + }
189 + "author" | "creator" => {
190 + item.insert("author".into(), get_text_content(&child).into());
191 + }
192 + "category" => {
193 + let tags_arr = item
194 + .get("tags")
195 + .and_then(|existing| existing.clone().try_cast::<rhai::Array>())
196 + .unwrap_or_default();
197 + let mut tags_arr = tags_arr;
198 + tags_arr.push(get_text_content(&child).into());
199 + item.insert("tags".into(), tags_arr.into());
200 + }
201 + _ => {}
202 + }
203 + }
204 +
205 + // If no guid, use link as id; if no link either, synthesize from title + pubDate hash
206 + if !item.contains_key("id") {
207 + if let Some(link) = item.get("link") {
208 + item.insert("id".into(), link.clone());
209 + } else {
210 + // Synthesize a deterministic ID from available fields so the same
211 + // item always gets the same ID across fetches.
212 + let title = item
213 + .get("title")
214 + .and_then(|v| v.clone().try_cast::<String>())
215 + .unwrap_or_default();
216 + let summary = item
217 + .get("summary")
218 + .and_then(|v| v.clone().try_cast::<String>())
219 + .unwrap_or_default();
220 + let published = item
221 + .get("published")
222 + .map(|v| v.to_string())
223 + .unwrap_or_default();
224 +
225 + use std::hash::{Hash, Hasher};
226 + let mut hasher = std::collections::hash_map::DefaultHasher::new();
227 + title.hash(&mut hasher);
228 + summary.hash(&mut hasher);
229 + published.hash(&mut hasher);
230 + let hash = hasher.finish();
231 + item.insert("id".into(), Dynamic::from(format!("synth-{:016x}", hash)));
232 + }
233 + }
234 +
235 + item.into()
236 + }
@@ -0,0 +1,134 @@
1 + use super::*;
2 +
3 + /// Convert serde_json::Value to Rhai Dynamic.
4 + pub(crate) fn json_to_dynamic(val: serde_json::Value) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
5 + match val {
6 + serde_json::Value::Null => Ok(Dynamic::UNIT),
7 + serde_json::Value::Bool(b) => Ok(b.into()),
8 + serde_json::Value::Number(n) => {
9 + if let Some(i) = n.as_i64() {
10 + Ok(i.into())
11 + } else if let Some(f) = n.as_f64() {
12 + Ok(f.into())
13 + } else {
14 + Ok(n.to_string().into())
15 + }
16 + }
17 + serde_json::Value::String(s) => Ok(s.into()),
18 + serde_json::Value::Array(arr) => {
19 + let rhai_arr: Result<rhai::Array, _> = arr.into_iter().map(json_to_dynamic).collect();
20 + Ok(rhai_arr?.into())
21 + }
22 + serde_json::Value::Object(obj) => {
23 + let mut map = Map::new();
24 + for (k, v) in obj {
25 + map.insert(k.into(), json_to_dynamic(v)?);
26 + }
27 + Ok(map.into())
28 + }
29 + }
30 + }
31 +
32 + /// Parse a JSON Feed (1.0/1.1) string into the same structure as `parse_feed_xml`:
33 + /// `{ title, link, entries: [{ id, title, summary, link, published, author, tags }] }`
34 + ///
35 + /// JSON Feed spec: <https://www.jsonfeed.org/version/1.1/>
36 + pub(crate) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
37 + let json: serde_json::Value =
38 + serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
39 +
40 + let obj = json
41 + .as_object()
42 + .ok_or("JSON Feed root must be an object")?;
43 +
44 + // Validate it's a JSON Feed by checking the version field
45 + let version = obj
46 + .get("version")
47 + .and_then(|v| v.as_str())
48 + .unwrap_or("");
49 + if !version.starts_with("https://jsonfeed.org/version/") {
50 + return Err(format!("Not a valid JSON Feed: version = {:?}", version).into());
51 + }
52 +
53 + let mut feed = Map::new();
54 +
55 + if let Some(title) = obj.get("title").and_then(|v| v.as_str()) {
56 + feed.insert("title".into(), title.to_string().into());
57 + }
58 + if let Some(link) = obj.get("home_page_url").and_then(|v| v.as_str()) {
59 + feed.insert("link".into(), link.to_string().into());
60 + }
61 +
62 + let mut entries: rhai::Array = Vec::new();
63 +
64 + if let Some(items) = obj.get("items").and_then(|v| v.as_array()) {
65 + for item in items {
66 + if let Some(item_obj) = item.as_object() {
67 + let mut entry = Map::new();
68 +
69 + if let Some(id) = item_obj.get("id").and_then(|v| v.as_str()) {
70 + entry.insert("id".into(), id.to_string().into());
71 + }
72 + if let Some(title) = item_obj.get("title").and_then(|v| v.as_str()) {
73 + entry.insert("title".into(), title.to_string().into());
74 + }
75 + if let Some(url) = item_obj.get("url").and_then(|v| v.as_str()) {
76 + entry.insert("link".into(), url.to_string().into());
77 + }
78 +
79 + // Prefer content_html over content_text for body/summary
80 + let body = item_obj
81 + .get("content_html")
82 + .and_then(|v| v.as_str())
83 + .or_else(|| item_obj.get("content_text").and_then(|v| v.as_str()))
84 + .or_else(|| item_obj.get("summary").and_then(|v| v.as_str()));
85 + if let Some(body) = body {
86 + entry.insert("summary".into(), body.to_string().into());
87 + }
88 +
89 + // Published date
90 + if let Some(date_str) = item_obj
91 + .get("date_published")
92 + .and_then(|v| v.as_str())
93 + {
94 + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
95 + entry.insert("published".into(), dt.timestamp().into());
96 + }
97 + }
98 +
99 + // Author: JSON Feed 1.1 uses "authors" array, 1.0 uses "author" object
100 + let author_name = item_obj
101 + .get("authors")
102 + .and_then(|v| v.as_array())
103 + .and_then(|arr| arr.first())
104 + .and_then(|a| a.get("name"))
105 + .and_then(|v| v.as_str())
106 + .or_else(|| {
107 + item_obj
108 + .get("author")
109 + .and_then(|v| v.get("name"))
110 + .and_then(|v| v.as_str())
111 + });
112 + if let Some(name) = author_name {
113 + entry.insert("author".into(), name.to_string().into());
114 + }
115 +
116 + // Tags
117 + if let Some(tags) = item_obj.get("tags").and_then(|v| v.as_array()) {
118 + let tags_arr: rhai::Array = tags
119 + .iter()
120 + .filter_map(|t| t.as_str().map(|s| Dynamic::from(s.to_string())))
121 + .collect();
122 + if !tags_arr.is_empty() {
123 + entry.insert("tags".into(), tags_arr.into());
124 + }
125 + }
126 +
127 + entries.push(entry.into());
128 + }
129 + }
130 + }
131 +
132 + feed.insert("entries".into(), entries.into());
133 + Ok(feed.into())
134 + }
@@ -0,0 +1,681 @@
1 + //! Rhai <-> Rust type conversions: JSON, XML feed parsing, and Dynamic-to-domain mapping.
2 + //!
3 + //! # Plugin `fetch()` return contract
4 + //!
5 + //! A Rhai plugin's `fetch(config, cursor)` function must return a map with:
6 + //!
7 + //! - **`items`** (array, required) -- array of item maps, each containing:
8 + //! - `id` (string or `{source, item_id}` map, required)
9 + //! - `bite` (map, optional) -- `{author, text, secondary?, indicator?}`
10 + //! - `content` (map, optional) -- `{title?, body?, url?, media?}`
11 + //! - `meta` (map, optional) -- `{source_name?, published_at?, score?, tags?}`
12 + //! - **`has_more`** (bool, optional, default `false`) -- signals additional pages.
13 + //! - **`next_cursor`** (string, optional) -- opaque cursor passed to the next
14 + //! `fetch()` call for pagination.
15 + //!
16 + //! Missing optional fields fall back to sensible defaults (empty strings,
17 + //! current timestamp, etc.). See [`dynamic_to_fetch_result`] and
18 + //! [`dynamic_to_feed_item`] for the full conversion logic.
19 +
20 + use bb_interface::{
21 + BiteDisplay, BusserCapabilities, BusserConfig, ConfigField, ConfigFieldType, ConfigSchema,
22 + FeedItem, FeedItemContent, FeedItemId, FeedItemMeta, FetchResult, ItemAction,
23 + };
24 + use rhai::{Dynamic, Map};
25 +
26 + use super::RhaiPluginError;
27 +
28 + mod json;
29 + mod feed_parse;
30 + mod domain;
31 +
32 + pub(crate) use json::*;
33 + pub(crate) use feed_parse::*;
34 + pub(crate) use domain::*;
35 +
36 + #[cfg(test)]
37 + mod tests {
38 + use super::*;
39 + use rhai::{Dynamic, Map};
40 + use std::collections::HashMap;
41 +
42 + // --- json_to_dynamic ---
43 +
44 + #[test]
45 + fn json_null_to_unit() {
46 + let d = json_to_dynamic(serde_json::Value::Null).unwrap();
47 + assert!(d.is_unit());
48 + }
49 +
50 + #[test]
51 + fn json_bool_to_dynamic() {
52 + let d = json_to_dynamic(serde_json::json!(true)).unwrap();
53 + assert!(d.try_cast::<bool>().unwrap());
54 + }
55 +
56 + #[test]
57 + fn json_int_to_dynamic() {
58 + let d = json_to_dynamic(serde_json::json!(42)).unwrap();
59 + assert_eq!(d.try_cast::<i64>().unwrap(), 42);
60 + }
61 +
62 + #[test]
63 + #[allow(clippy::approx_constant)]
64 + fn json_float_to_dynamic() {
65 + let d = json_to_dynamic(serde_json::json!(3.14)).unwrap();
66 + let f = d.try_cast::<f64>().unwrap();
67 + assert!((f - 3.14).abs() < f64::EPSILON);
68 + }
69 +
70 + #[test]
71 + fn json_string_to_dynamic() {
72 + let d = json_to_dynamic(serde_json::json!("hello")).unwrap();
73 + assert_eq!(d.try_cast::<String>().unwrap(), "hello");
74 + }
75 +
76 + #[test]
77 + fn json_array_to_dynamic() {
78 + let d = json_to_dynamic(serde_json::json!([1, 2, 3])).unwrap();
79 + let arr = d.try_cast::<rhai::Array>().unwrap();
80 + assert_eq!(arr.len(), 3);
81 + assert_eq!(arr[0].clone().try_cast::<i64>().unwrap(), 1);
82 + }
83 +
84 + #[test]
85 + fn json_object_to_dynamic() {
86 + let d = json_to_dynamic(serde_json::json!({"key": "value"})).unwrap();
87 + let map = d.try_cast::<Map>().unwrap();
88 + assert_eq!(
89 + map.get("key").unwrap().clone().try_cast::<String>().unwrap(),
90 + "value"
91 + );
92 + }
93 +
94 + // --- parse_feed_xml (Atom) ---
95 +
96 + #[test]
97 + fn parse_atom_feed_title_and_link() {
98 + let xml = r#"<?xml version="1.0"?>
99 + <feed xmlns="http://www.w3.org/2005/Atom">
100 + <title>My Feed</title>
101 + <link href="https://example.com"/>
102 + <entry>
103 + <id>urn:entry:1</id>
104 + <title>First Post</title>
105 + <link href="https://example.com/1"/>
106 + <published>2025-01-15T10:00:00Z</published>
107 + </entry>
108 + </feed>"#;
109 +
110 + let result = parse_feed_xml(xml).unwrap();
111 + let map = result.try_cast::<Map>().unwrap();
112 +
113 + assert_eq!(
114 + map.get("title").unwrap().clone().try_cast::<String>().unwrap(),
115 + "My Feed"
116 + );
117 + assert_eq!(
118 + map.get("link").unwrap().clone().try_cast::<String>().unwrap(),
119 + "https://example.com"
120 + );
121 +
122 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
123 + assert_eq!(entries.len(), 1);
124 +
125 + let entry = entries[0].clone().try_cast::<Map>().unwrap();
126 + assert_eq!(entry.get("id").unwrap().clone().try_cast::<String>().unwrap(), "urn:entry:1");
127 + assert_eq!(entry.get("title").unwrap().clone().try_cast::<String>().unwrap(), "First Post");
128 + }
129 +
130 + // --- parse_feed_xml (RSS) ---
131 +
132 + #[test]
133 + fn parse_rss_feed_title_and_items() {
134 + let xml = r#"<?xml version="1.0"?>
135 + <rss version="2.0">
136 + <channel>
137 + <title>RSS Feed</title>
138 + <link>https://example.com</link>
139 + <item>
140 + <guid>item-1</guid>
141 + <title>RSS Post</title>
142 + <description>Post body</description>
143 + <link>https://example.com/rss-1</link>
144 + <pubDate>Mon, 15 Jan 2025 10:00:00 +0000</pubDate>
145 + </item>
146 + </channel>
147 + </rss>"#;
148 +
149 + let result = parse_feed_xml(xml).unwrap();
150 + let map = result.try_cast::<Map>().unwrap();
151 +
152 + assert_eq!(
153 + map.get("title").unwrap().clone().try_cast::<String>().unwrap(),
154 + "RSS Feed"
155 + );
156 +
157 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
158 + assert_eq!(entries.len(), 1);
159 +
160 + let item = entries[0].clone().try_cast::<Map>().unwrap();
161 + assert_eq!(item.get("id").unwrap().clone().try_cast::<String>().unwrap(), "item-1");
162 + assert_eq!(item.get("title").unwrap().clone().try_cast::<String>().unwrap(), "RSS Post");
163 + assert_eq!(item.get("summary").unwrap().clone().try_cast::<String>().unwrap(), "Post body");
164 + }
165 +
166 + #[test]
167 + fn parse_rss_item_no_guid_uses_link() {
168 + let xml = r#"<?xml version="1.0"?>
169 + <rss version="2.0">
170 + <channel>
171 + <title>No GUID Feed</title>
172 + <link>https://example.com</link>
173 + <item>
174 + <title>No GUID Post</title>
175 + <link>https://example.com/post</link>
176 + </item>
177 + </channel>
178 + </rss>"#;
179 +
180 + let result = parse_feed_xml(xml).unwrap();
181 + let map = result.try_cast::<Map>().unwrap();
182 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
183 + let item = entries[0].clone().try_cast::<Map>().unwrap();
184 +
185 + assert_eq!(
186 + item.get("id").unwrap().clone().try_cast::<String>().unwrap(),
187 + "https://example.com/post"
188 + );
189 + }
190 +
191 + // --- RSS items without guid or link get synthesized ID ---
192 +
193 + #[test]
194 + fn rss_item_no_guid_no_link_gets_synthesized_id() {
195 + let xml = r#"<?xml version="1.0"?>
196 + <rss version="2.0">
197 + <channel>
198 + <title>Feed</title>
199 + <link>https://example.com</link>
200 + <item>
201 + <title>Orphan Post</title>
202 + <description>No guid or link</description>
203 + </item>
204 + </channel>
205 + </rss>"#;
206 +
207 + let result = parse_feed_xml(xml).unwrap();
208 + let map = result.try_cast::<Map>().unwrap();
209 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
210 + let item = entries[0].clone().try_cast::<Map>().unwrap();
211 +
212 + let id = item.get("id").unwrap().clone().try_cast::<String>().unwrap();
213 + assert!(id.starts_with("synth-"), "synthesized ID should start with 'synth-', got: {}", id);
214 + }
215 +
216 + #[test]
217 + fn rss_items_no_guid_no_link_different_titles_get_different_ids() {
218 + let xml = r#"<?xml version="1.0"?>
219 + <rss version="2.0">
220 + <channel>
221 + <title>Feed</title>
222 + <link>https://example.com</link>
223 + <item>
224 + <title>Post A</title>
225 + <description>First item</description>
226 + </item>
227 + <item>
228 + <title>Post B</title>
229 + <description>Second item</description>
230 + </item>
231 + </channel>
232 + </rss>"#;
233 +
234 + let result = parse_feed_xml(xml).unwrap();
235 + let map = result.try_cast::<Map>().unwrap();
236 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
237 +
238 + let id_a = entries[0].clone().try_cast::<Map>().unwrap()
239 + .get("id").unwrap().clone().try_cast::<String>().unwrap();
240 + let id_b = entries[1].clone().try_cast::<Map>().unwrap()
241 + .get("id").unwrap().clone().try_cast::<String>().unwrap();
242 +
243 + assert_ne!(id_a, id_b, "different items should get different synthesized IDs");
244 + }
245 +
246 + #[test]
247 + fn rss_item_with_guid_uses_guid() {
248 + let xml = r#"<?xml version="1.0"?>
249 + <rss version="2.0">
250 + <channel>
251 + <title>Feed</title>
252 + <link>https://example.com</link>
253 + <item>
254 + <guid>my-guid-123</guid>
255 + <title>Guided Post</title>
256 + </item>
257 + </channel>
258 + </rss>"#;
259 +
260 + let result = parse_feed_xml(xml).unwrap();
261 + let map = result.try_cast::<Map>().unwrap();
262 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
263 + let item = entries[0].clone().try_cast::<Map>().unwrap();
264 +
265 + assert_eq!(
266 + item.get("id").unwrap().clone().try_cast::<String>().unwrap(),
267 + "my-guid-123"
268 + );
269 + }
270 +
271 + #[test]
272 + fn rss_item_synthesized_id_is_deterministic() {
273 + let xml = r#"<?xml version="1.0"?>
274 + <rss version="2.0">
275 + <channel>
276 + <title>Feed</title>
277 + <link>https://example.com</link>
278 + <item>
279 + <title>Stable Post</title>
280 + <description>Same content</description>
281 + </item>
282 + </channel>
283 + </rss>"#;
284 +
285 + let result1 = parse_feed_xml(xml).unwrap();
286 + let result2 = parse_feed_xml(xml).unwrap();
287 +
288 + let get_id = |result: Dynamic| -> String {
289 + let map = result.try_cast::<Map>().unwrap();
290 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
291 + let item = entries[0].clone().try_cast::<Map>().unwrap();
292 + item.get("id").unwrap().clone().try_cast::<String>().unwrap()
293 + };
294 +
295 + assert_eq!(get_id(result1), get_id(result2), "same content should produce same ID");
296 + }
297 +
298 + // --- parse_xml_to_dynamic ---
299 +
300 + #[test]
301 + fn parse_xml_simple_element() {
302 + let xml = "<root>hello</root>";
303 + let result = parse_xml_to_dynamic(xml).unwrap();
304 + let map = result.try_cast::<Map>().unwrap();
305 + assert_eq!(map.get("tag").unwrap().clone().try_cast::<String>().unwrap(), "root");
306 + assert_eq!(map.get("text").unwrap().clone().try_cast::<String>().unwrap(), "hello");
307 + }
308 +
309 + #[test]
310 + fn parse_xml_with_attributes() {
311 + let xml = r#"<item id="42" type="post"/>"#;
312 + let result = parse_xml_to_dynamic(xml).unwrap();
313 + let map = result.try_cast::<Map>().unwrap();
314 + let attrs = map.get("attrs").unwrap().clone().try_cast::<Map>().unwrap();
315 + assert_eq!(attrs.get("id").unwrap().clone().try_cast::<String>().unwrap(), "42");
316 + assert_eq!(attrs.get("type").unwrap().clone().try_cast::<String>().unwrap(), "post");
317 + }
318 +
319 + #[test]
320 + fn parse_xml_with_children() {
321 + let xml = "<root><child>text</child></root>";
322 + let result = parse_xml_to_dynamic(xml).unwrap();
323 + let map = result.try_cast::<Map>().unwrap();
324 + let children = map.get("children").unwrap().clone().try_cast::<rhai::Array>().unwrap();
325 + assert_eq!(children.len(), 1);
326 + let child = children[0].clone().try_cast::<Map>().unwrap();
327 + assert_eq!(child.get("tag").unwrap().clone().try_cast::<String>().unwrap(), "child");
328 + assert_eq!(child.get("text").unwrap().clone().try_cast::<String>().unwrap(), "text");
329 + }
330 +
331 + #[test]
332 + fn parse_xml_invalid_returns_error() {
333 + let result = parse_xml_to_dynamic("<not closed");
334 + assert!(result.is_err());
335 + }
336 +
337 + // --- busser_config_to_dynamic ---
338 +
339 + #[test]
340 + fn config_to_dynamic_options_and_feeds() {
341 + let config = BusserConfig {
342 + options: HashMap::from([
343 + ("key1".into(), "val1".into()),
344 + ("key2".into(), "val2".into()),
345 + ]),
346 + feeds: vec!["https://feed.example.com".into()],
347 + };
348 +
349 + let d = busser_config_to_dynamic(&config);
350 + let map = d.try_cast::<Map>().unwrap();
351 +
352 + assert_eq!(map.get("key1").unwrap().clone().try_cast::<String>().unwrap(), "val1");
353 + assert_eq!(map.get("key2").unwrap().clone().try_cast::<String>().unwrap(), "val2");
354 + assert_eq!(
355 + map.get("feed_url").unwrap().clone().try_cast::<String>().unwrap(),
356 + "https://feed.example.com"
357 + );
358 +
359 + let feeds = map.get("feeds").unwrap().clone().try_cast::<rhai::Array>().unwrap();
360 + assert_eq!(feeds.len(), 1);
361 + }
362 +
363 + #[test]
364 + fn config_to_dynamic_no_feeds() {
365 + let config = BusserConfig {
366 + options: HashMap::new(),
367 + feeds: vec![],
368 + };
369 +
370 + let d = busser_config_to_dynamic(&config);
371 + let map = d.try_cast::<Map>().unwrap();
372 +
373 + assert!(!map.contains_key("feed_url"));
374 + let feeds = map.get("feeds").unwrap().clone().try_cast::<rhai::Array>().unwrap();
375 + assert!(feeds.is_empty());
376 + }
377 +
378 + // --- dynamic_to_capabilities ---
379 +
380 + #[test]
381 + fn capabilities_defaults_from_empty_map() {
382 + let d = Dynamic::from(Map::new());
383 + let caps = dynamic_to_capabilities(d);
384 + assert!(!caps.supports_pagination);
385 + assert!(!caps.supports_search);
386 + assert!(!caps.requires_auth);
387 + }
388 +
389 + #[test]
390 + fn capabilities_all_true() {
391 + let mut map = Map::new();
392 + map.insert("supports_pagination".into(), true.into());
393 + map.insert("supports_search".into(), true.into());
394 + map.insert("supports_date_filter".into(), true.into());
395 + map.insert("supports_read_state".into(), true.into());
396 + map.insert("supports_starring".into(), true.into());
397 + map.insert("requires_auth".into(), true.into());
398 + map.insert("fetch_interval_secs".into(), Dynamic::from(300_i64));
399 +
400 + let caps = dynamic_to_capabilities(map.into());
401 + assert!(caps.supports_pagination);
402 + assert!(caps.supports_search);
403 + assert!(caps.supports_date_filter);
404 + assert!(caps.supports_read_state);
405 + assert!(caps.supports_starring);
406 + assert!(caps.requires_auth);
407 + assert_eq!(caps.fetch_interval_secs, 300);
408 + }
409 +
410 + #[test]
411 + fn capabilities_non_map_returns_defaults() {
412 + let d = Dynamic::from("not a map");
413 + let caps = dynamic_to_capabilities(d);
414 + assert!(!caps.supports_pagination);
415 + }
416 +
417 + #[test]
418 + fn capabilities_negative_interval_clamped_to_zero() {
419 + let mut map = Map::new();
420 + map.insert("fetch_interval_secs".into(), Dynamic::from(-10_i64));
421 + let caps = dynamic_to_capabilities(map.into());
422 + assert_eq!(caps.fetch_interval_secs, 0);
423 + }
424 +
425 + // --- dynamic_to_config_schema ---
426 +
427 + #[test]
428 + fn config_schema_with_fields() {
429 + let mut field_map = Map::new();
430 + field_map.insert("key".into(), Dynamic::from("feed_url".to_string()));
431 + field_map.insert("label".into(), Dynamic::from("Feed URL".to_string()));
432 + field_map.insert("field_type".into(), Dynamic::from("url".to_string()));
433 + field_map.insert("required".into(), true.into());
434 +
435 + let fields: rhai::Array = vec![Dynamic::from(field_map)];
436 +
437 + let mut map = Map::new();
438 + map.insert("description".into(), Dynamic::from("Test plugin".to_string()));
439 + map.insert("fields".into(), Dynamic::from(fields));
440 +
441 + let schema = dynamic_to_config_schema(Dynamic::from(map)).unwrap();
442 + assert_eq!(schema.description, "Test plugin");
443 + assert_eq!(schema.fields.len(), 1);
444 + assert_eq!(schema.fields[0].key, "feed_url");
445 + assert_eq!(schema.fields[0].label, "Feed URL");
446 + assert!(matches!(schema.fields[0].field_type, ConfigFieldType::Url));
447 + assert!(schema.fields[0].required);
448 + }
449 +
450 + #[test]
451 + fn config_schema_non_map_returns_error() {
452 + let result = dynamic_to_config_schema(Dynamic::from("not a map"));
453 + assert!(result.is_err());
454 + }
455 +
456 + #[test]
457 + fn config_schema_field_type_defaults_to_text() {
458 + let mut field_map = Map::new();
459 + field_map.insert("key".into(), Dynamic::from("name".to_string()));
460 + field_map.insert("label".into(), Dynamic::from("Name".to_string()));
461 +
462 + let fields: rhai::Array = vec![Dynamic::from(field_map)];
463 + let mut map = Map::new();
464 + map.insert("fields".into(), Dynamic::from(fields));
465 +
466 + let schema = dynamic_to_config_schema(Dynamic::from(map)).unwrap();
467 + assert!(matches!(schema.fields[0].field_type, ConfigFieldType::Text));
468 + }
469 +
470 + // --- dynamic_to_fetch_result ---
471 +
472 + #[test]
473 + fn fetch_result_with_items() {
474 + let mut item = Map::new();
475 + item.insert("id".into(), Dynamic::from("post-1".to_string()));
476 +
477 + let mut bite = Map::new();
478 + bite.insert("author".into(), Dynamic::from("Alice".to_string()));
479 + bite.insert("text".into(), Dynamic::from("Hello".to_string()));
480 + item.insert("bite".into(), Dynamic::from(bite));
481 +
482 + let items: rhai::Array = vec![Dynamic::from(item)];
483 + let mut map = Map::new();
484 + map.insert("items".into(), Dynamic::from(items));
485 + map.insert("has_more".into(), true.into());
486 + map.insert("next_cursor".into(), Dynamic::from("page2".to_string()));
487 +
488 + let result = dynamic_to_fetch_result(Dynamic::from(map), "test-source").unwrap();
489 + assert_eq!(result.items.len(), 1);
490 + assert!(result.has_more);
491 + assert_eq!(result.next_cursor.as_deref(), Some("page2"));
492 + }
493 +
494 + #[test]
495 + fn fetch_result_missing_items_returns_error() {
496 + let map = Map::new();
497 + let result = dynamic_to_fetch_result(Dynamic::from(map), "test");
498 + assert!(result.is_err());
499 + }
500 +
Lines truncated