max / balanced_breakfast
1 file changed,
+76 insertions,
-46 deletions
| @@ -217,6 +217,20 @@ pub(super) fn register_host_functions( | |||
| 217 | 217 | request_counter: Arc<AtomicUsize>, | |
| 218 | 218 | fetch_deadline: Arc<AtomicU64>, | |
| 219 | 219 | ) { | |
| 220 | + | register_http_fns(engine, request_counter, fetch_deadline); | |
| 221 | + | register_parse_fns(engine); | |
| 222 | + | register_string_fns(engine); | |
| 223 | + | register_util_fns(engine); | |
| 224 | + | register_error_fns(engine); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Register the HTTP fetch builtins (`http_get`, `http_get_json`). These capture the | |
| 228 | + | /// per-fetch request counter, deadline, and a shared `ureq` agent. | |
| 229 | + | fn register_http_fns( | |
| 230 | + | engine: &mut Engine, | |
| 231 | + | request_counter: Arc<AtomicUsize>, | |
| 232 | + | fetch_deadline: Arc<AtomicU64>, | |
| 233 | + | ) { | |
| 220 | 234 | // HTTP GET returning string (see trust model above) | |
| 221 | 235 | let counter = request_counter.clone(); | |
| 222 | 236 | let deadline = fetch_deadline.clone(); | |
| @@ -267,7 +281,11 @@ pub(super) fn register_host_functions( | |||
| 267 | 281 | json_to_dynamic(json) | |
| 268 | 282 | }, | |
| 269 | 283 | ); | |
| 284 | + | } | |
| 270 | 285 | ||
| 286 | + | /// Register the parsing builtins (`parse_json`, `parse_xml`, `parse_feed`, | |
| 287 | + | /// `parse_datetime`, `parse_int`). | |
| 288 | + | fn register_parse_fns(engine: &mut Engine) { | |
| 271 | 289 | // Parse JSON string to Dynamic | |
| 272 | 290 | engine.register_fn( | |
| 273 | 291 | "parse_json", | |
| @@ -303,14 +321,30 @@ pub(super) fn register_host_functions( | |||
| 303 | 321 | }, | |
| 304 | 322 | ); | |
| 305 | 323 | ||
| 306 | - | // Current UTC timestamp as Unix epoch seconds (i64). | |
| 307 | - | engine.register_fn("timestamp_now", || -> i64 { chrono::Utc::now().timestamp() }); | |
| 324 | + | // Parse ISO 8601 date to timestamp | |
| 325 | + | engine.register_fn( | |
| 326 | + | "parse_datetime", | |
| 327 | + | |date_str: &str| -> Result<i64, Box<rhai::EvalAltResult>> { | |
| 328 | + | chrono::DateTime::parse_from_rfc3339(date_str) | |
| 329 | + | .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str)) | |
| 330 | + | .map(|dt| dt.timestamp()) | |
| 331 | + | .map_err(|e| format!("Date parse error: {}", e).into()) | |
| 332 | + | }, | |
| 333 | + | ); | |
| 308 | 334 | ||
| 309 | - | // Convert HTML to readable markdown. | |
| 310 | - | engine.register_fn("html_to_text", |html: &str| -> String { | |
| 311 | - | pter::convert(html) | |
| 335 | + | // Parse string to integer. Returns Dynamic::UNIT (Rhai's nil) on failure | |
| 336 | + | // instead of Option<i64> because Rhai doesn't natively handle Rust Options. | |
| 337 | + | engine.register_fn("parse_int", |text: &str| -> Dynamic { | |
| 338 | + | match text.parse::<i64>() { | |
| 339 | + | Ok(n) => n.into(), | |
| 340 | + | Err(_) => Dynamic::UNIT, | |
| 341 | + | } | |
| 312 | 342 | }); | |
| 343 | + | } | |
| 313 | 344 | ||
| 345 | + | /// Register the string-manipulation builtins (`truncate`, `str_contains`, `str_split`, | |
| 346 | + | /// `str_replace`, `str_trim`). | |
| 347 | + | fn register_string_fns(engine: &mut Engine) { | |
| 314 | 348 | // Truncate text with ellipsis (character-count aware for multibyte UTF-8) | |
| 315 | 349 | engine.register_fn("truncate", |text: &str, max_len: i64| -> String { | |
| 316 | 350 | let max = max_len.max(0) as usize; | |
| @@ -342,17 +376,18 @@ pub(super) fn register_host_functions( | |||
| 342 | 376 | ||
| 343 | 377 | // String trim | |
| 344 | 378 | engine.register_fn("str_trim", |text: &str| -> String { text.trim().to_string() }); | |
| 379 | + | } | |
| 345 | 380 | ||
| 346 | - | // Parse ISO 8601 date to timestamp | |
| 347 | - | engine.register_fn( | |
| 348 | - | "parse_datetime", | |
| 349 | - | |date_str: &str| -> Result<i64, Box<rhai::EvalAltResult>> { | |
| 350 | - | chrono::DateTime::parse_from_rfc3339(date_str) | |
| 351 | - | .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str)) | |
| 352 | - | .map(|dt| dt.timestamp()) | |
| 353 | - | .map_err(|e| format!("Date parse error: {}", e).into()) | |
| 354 | - | }, | |
| 355 | - | ); | |
| 381 | + | /// Register the remaining utility builtins (`timestamp_now`, `html_to_text`, | |
| 382 | + | /// `debug_print`, `strip_tracking`, `extract_article`). | |
| 383 | + | fn register_util_fns(engine: &mut Engine) { | |
| 384 | + | // Current UTC timestamp as Unix epoch seconds (i64). | |
| 385 | + | engine.register_fn("timestamp_now", || -> i64 { chrono::Utc::now().timestamp() }); | |
| 386 | + | ||
| 387 | + | // Convert HTML to readable markdown. | |
| 388 | + | engine.register_fn("html_to_text", |html: &str| -> String { | |
| 389 | + | pter::convert(html) | |
| 390 | + | }); | |
| 356 | 391 | ||
| 357 | 392 | // Debug print — outputs to tracing at debug level, visible in dev console. | |
| 358 | 393 | engine.register_fn("debug_print", |val: Dynamic| { | |
| @@ -364,15 +399,33 @@ pub(super) fn register_host_functions( | |||
| 364 | 399 | url_cleaner::strip_tracking_params(url) | |
| 365 | 400 | }); | |
| 366 | 401 | ||
| 367 | - | // Parse string to integer. Returns Dynamic::UNIT (Rhai's nil) on failure | |
| 368 | - | // instead of Option<i64> because Rhai doesn't natively handle Rust Options. | |
| 369 | - | engine.register_fn("parse_int", |text: &str| -> Dynamic { | |
| 370 | - | match text.parse::<i64>() { | |
| 371 | - | Ok(n) => n.into(), | |
| 372 | - | Err(_) => Dynamic::UNIT, | |
| 373 | - | } | |
| 402 | + | // Extract main article content from HTML using the readability algorithm. | |
| 403 | + | // Returns a map with "title", "content" (cleaned HTML), and "text" (plain text). | |
| 404 | + | engine.register_fn("extract_article", |html: String| -> rhai::Map { | |
| 405 | + | let mut map = rhai::Map::new(); | |
| 406 | + | let mut readability = readable_readability::Readability::new(); | |
| 407 | + | let (node, metadata) = readability.parse(&html); | |
| 408 | + | ||
| 409 | + | // Serialize the extracted DOM node to an HTML string. | |
| 410 | + | let mut content = Vec::new(); | |
| 411 | + | node.serialize(&mut content).unwrap_or_default(); | |
| 412 | + | let content_html = String::from_utf8_lossy(&content).to_string(); | |
| 413 | + | ||
| 414 | + | // Readable markdown via pter | |
| 415 | + | let text = pter::convert(&content_html); | |
| 416 | + | ||
| 417 | + | map.insert( | |
| 418 | + | "title".into(), | |
| 419 | + | Dynamic::from(metadata.article_title.unwrap_or_default()), | |
| 420 | + | ); | |
| 421 | + | map.insert("content".into(), Dynamic::from(content_html)); | |
| 422 | + | map.insert("text".into(), Dynamic::from(text)); | |
| 423 | + | map | |
| 374 | 424 | }); | |
| 425 | + | } | |
| 375 | 426 | ||
| 427 | + | /// Register the structured-error signaling builtins (`throw_*`). | |
| 428 | + | fn register_error_fns(engine: &mut Engine) { | |
| 376 | 429 | // ── Structured error signaling ────────────────────────────────── | |
| 377 | 430 | // Plugins can throw categorized errors that the host recognizes. | |
| 378 | 431 | ||
| @@ -403,32 +456,9 @@ pub(super) fn register_host_functions( | |||
| 403 | 456 | Err(format!("{BB_ERR_PREFIX}parse:{msg}").into()) | |
| 404 | 457 | }, | |
| 405 | 458 | ); | |
| 406 | - | ||
| 407 | - | // Extract main article content from HTML using the readability algorithm. | |
| 408 | - | // Returns a map with "title", "content" (cleaned HTML), and "text" (plain text). | |
| 409 | - | engine.register_fn("extract_article", |html: String| -> rhai::Map { | |
| 410 | - | let mut map = rhai::Map::new(); | |
| 411 | - | let mut readability = readable_readability::Readability::new(); | |
| 412 | - | let (node, metadata) = readability.parse(&html); | |
| 413 | - | ||
| 414 | - | // Serialize the extracted DOM node to an HTML string. | |
| 415 | - | let mut content = Vec::new(); | |
| 416 | - | node.serialize(&mut content).unwrap_or_default(); | |
| 417 | - | let content_html = String::from_utf8_lossy(&content).to_string(); | |
| 418 | - | ||
| 419 | - | // Readable markdown via pter | |
| 420 | - | let text = pter::convert(&content_html); | |
| 421 | - | ||
| 422 | - | map.insert( | |
| 423 | - | "title".into(), | |
| 424 | - | Dynamic::from(metadata.article_title.unwrap_or_default()), | |
| 425 | - | ); | |
| 426 | - | map.insert("content".into(), Dynamic::from(content_html)); | |
| 427 | - | map.insert("text".into(), Dynamic::from(text)); | |
| 428 | - | map | |
| 429 | - | }); | |
| 430 | 459 | } | |
| 431 | 460 | ||
| 461 | + | ||
| 432 | 462 | #[cfg(test)] | |
| 433 | 463 | mod tests { | |
| 434 | 464 | use std::sync::atomic::{AtomicUsize, Ordering}; |