//! Rhai API module for plugins. //! //! Provides the `goingson::` namespace with functions for: //! - Entity creation (tasks, projects, events) //! - File I/O (sandboxed) //! - CSV/JSON parsing //! - Logging and progress reporting //! - Date parsing use rhai::plugin::*; use rhai::{Dynamic, EvalAltResult, Map, Module}; use std::cell::RefCell; use goingson_core::{ ImportEntityType, ImportItem, ImportItemData, ImportParseResult, ImportTaskData, ImportProjectData, ImportEventData, }; // Thread-local context for plugin execution. thread_local! { static CONTEXT: RefCell = RefCell::new(PluginApiContext::default()); } /// Runtime context for plugin API calls. #[derive(Default)] pub struct PluginApiContext { /// File path being imported (for sandboxed file access). pub import_file_path: Option, /// Whether file_read capability is granted. pub can_read_files: bool, /// Whether database_write capability is granted. pub can_write_db: bool, /// Collected log entries. pub logs: Vec<(String, String)>, // (level, message) /// Current progress. pub progress: Option<(usize, usize, String)>, // (current, total, message) /// Projects cache for lookup. pub projects: Vec<(String, String)>, // (id, name) } impl PluginApiContext { /// Sets the context for the current thread. #[tracing::instrument(skip_all)] pub fn set(ctx: PluginApiContext) { CONTEXT.with(|c| *c.borrow_mut() = ctx); } /// Gets a copy of the current context (used in tests). #[cfg(test)] pub fn get() -> PluginApiContext { CONTEXT.with(|c| { let ctx = c.borrow(); PluginApiContext { import_file_path: ctx.import_file_path.clone(), can_read_files: ctx.can_read_files, can_write_db: ctx.can_write_db, logs: ctx.logs.clone(), progress: ctx.progress.clone(), projects: ctx.projects.clone(), } }) } /// Clears the context. #[tracing::instrument(skip_all)] pub fn clear() { CONTEXT.with(|c| *c.borrow_mut() = PluginApiContext::default()); } } /// API handle for plugins to interact with GoingsOn. pub struct PluginApi; impl PluginApi { /// Sets the import file path for sandboxed file access. #[tracing::instrument(skip_all)] pub fn set_import_file(path: &str) { CONTEXT.with(|c| { c.borrow_mut().import_file_path = Some(path.to_string()); }); } /// Sets the capabilities for the current execution. #[tracing::instrument(skip_all)] pub fn set_capabilities(file_read: bool, database_write: bool) { CONTEXT.with(|c| { let mut ctx = c.borrow_mut(); ctx.can_read_files = file_read; ctx.can_write_db = database_write; }); } /// Sets available projects for lookup. #[tracing::instrument(skip_all)] pub fn set_projects(projects: Vec<(String, String)>) { CONTEXT.with(|c| { c.borrow_mut().projects = projects; }); } /// Gets collected log entries. #[tracing::instrument(skip_all)] pub fn get_logs() -> Vec<(String, String)> { CONTEXT.with(|c| c.borrow().logs.clone()) } /// Gets current progress (used by plugin host for progress reporting). #[tracing::instrument(skip_all)] pub fn get_progress() -> Option<(usize, usize, String)> { CONTEXT.with(|c| c.borrow().progress.clone()) } } /// The goingson module exported to Rhai #[export_module] mod goingson_api { use super::*; // ============ File I/O Functions ============ #[rhai_fn(return_raw)] #[tracing::instrument(skip_all)] pub fn read_file(path: &str) -> Result> { CONTEXT.with(|c| { let ctx = c.borrow(); // Check capability if !ctx.can_read_files { return Err("Permission denied: file_read capability not granted".into()); } // Check if path matches the import file (sandboxing via canonical path comparison) if let Some(ref allowed) = ctx.import_file_path { let allowed_canonical = std::fs::canonicalize(allowed) .map_err(|e| format!("Cannot resolve import file path: {}", e))?; let request_canonical = std::fs::canonicalize(path) .map_err(|e| format!("Cannot resolve requested path: {}", e))?; // Allow reading the exact import file (canonical comparison prevents traversal) if allowed_canonical != request_canonical { return Err(format!( "Permission denied: can only read import file '{}'", allowed ).into()); } } else { return Err("No import file set for this execution".into()); } // Check file size before reading to prevent OOM on large files let metadata = std::fs::metadata(path) .map_err(|e| -> Box { format!("Failed to stat file '{}': {}", path, e).into() })?; let max_size = 10 * 1024 * 1024; // 10 MB if metadata.len() > max_size { return Err(format!( "File '{}' is {} bytes, exceeding {} byte limit", path, metadata.len(), max_size ).into()); } std::fs::read_to_string(path) .map_err(|e| format!("Failed to read file '{}': {}", path, e).into()) }) } #[rhai_fn(return_raw)] #[tracing::instrument(skip_all)] pub fn parse_csv(content: &str, options: Map) -> Result> { // Excel-exported CSVs start with a BOM that breaks header detection let content = content.strip_prefix('\u{FEFF}').unwrap_or(content); let has_header = options .get("has_header") .and_then(|v| v.as_bool().ok()) .unwrap_or(true); let delimiter = options .get("delimiter") .and_then(|v| v.clone().into_string().ok()) .and_then(|s| s.chars().next()) .unwrap_or(','); let mut reader = csv::ReaderBuilder::new() .has_headers(has_header) .delimiter(delimiter as u8) .flexible(true) .from_reader(content.as_bytes()); // Single-pass: determine headers, then iterate records from the same reader. // For has_header=true, headers() consumes the first row and caches it. // For has_header=false, peek the first record for column count, then // process it as data along with the rest. let (headers, first_record) = if has_header { let hdrs: Vec = reader .headers() .map_err(|e| -> Box { format!("Failed to read CSV headers: {}", e).into() })? .iter() .map(|s| s.to_string()) .collect(); (hdrs, None) } else { // Read first record to determine column count let first = reader.records().next(); match first { Some(Ok(record)) => { let hdrs: Vec = (0..record.len()).map(|i| format!("col_{}", i)).collect(); (hdrs, Some(record)) } _ => (Vec::new(), None), } }; let mut rows = rhai::Array::new(); // Process the first record if we consumed it for column detection let records_iter: Box>> = if let Some(first) = first_record { Box::new(std::iter::once(Ok(first)).chain(reader.records())) } else { Box::new(reader.records()) }; for result in records_iter { let record = result.map_err(|e| -> Box { format!("CSV parse error: {}", e).into() })?; let mut row_map = Map::new(); for (i, field) in record.iter().enumerate() { let key = headers.get(i).cloned().unwrap_or_else(|| format!("col_{}", i)); row_map.insert(key.into(), Dynamic::from(field.to_string())); } rows.push(Dynamic::from_map(row_map)); } Ok(rows) } #[rhai_fn(return_raw)] #[tracing::instrument(skip_all)] pub fn parse_json(content: &str) -> Result> { serde_json::from_str::(content) .map(json_to_dynamic) .map_err(|e| format!("JSON parse error: {}", e).into()) } // ============ Logging Functions ============ #[tracing::instrument(skip_all)] pub fn log_info(message: &str) { CONTEXT.with(|c| { c.borrow_mut().logs.push(("info".to_string(), message.to_string())); }); } #[tracing::instrument(skip_all)] pub fn log_warn(message: &str) { CONTEXT.with(|c| { c.borrow_mut().logs.push(("warn".to_string(), message.to_string())); }); } #[tracing::instrument(skip_all)] pub fn log_error(message: &str) { CONTEXT.with(|c| { c.borrow_mut().logs.push(("error".to_string(), message.to_string())); }); } // ============ Progress Functions ============ #[tracing::instrument(skip_all)] pub fn report_progress(current: i64, total: i64, message: &str) { CONTEXT.with(|c| { c.borrow_mut().progress = Some((current.max(0) as usize, total.max(0) as usize, message.to_string())); }); } // ============ Date Parsing ============ #[rhai_fn(return_raw)] #[tracing::instrument(skip_all)] pub fn parse_date(input: &str) -> Result> { // Try various common date formats let formats = [ "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%.fZ", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y", "%B %d, %Y", "%b %d, %Y", ]; let input = input.trim(); // If empty, return empty (optional field) if input.is_empty() { return Ok(String::new()); } for format in &formats { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(input, format) { return Ok(dt.format("%Y-%m-%dT%H:%M:%S").to_string()); } if let Ok(d) = chrono::NaiveDate::parse_from_str(input, format) { return Ok(d.format("%Y-%m-%d").to_string()); } } // Try parsing as ISO 8601 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(input) { return Ok(dt.format("%Y-%m-%dT%H:%M:%S").to_string()); } Err(format!("Could not parse date: '{}'", input).into()) } // ============ Project Lookup ============ #[tracing::instrument(skip_all)] pub fn find_project_by_name(name: &str) -> Dynamic { CONTEXT.with(|c| { let ctx = c.borrow(); let name_lower = name.to_lowercase(); for (id, project_name) in &ctx.projects { if project_name.to_lowercase() == name_lower { let mut map = Map::new(); map.insert("id".into(), Dynamic::from(id.clone())); map.insert("name".into(), Dynamic::from(project_name.clone())); return Dynamic::from_map(map); } } Dynamic::UNIT }) } #[tracing::instrument(skip_all)] pub fn list_projects() -> rhai::Array { CONTEXT.with(|c| { let ctx = c.borrow(); ctx.projects .iter() .map(|(id, name)| { let mut map = Map::new(); map.insert("id".into(), Dynamic::from(id.clone())); map.insert("name".into(), Dynamic::from(name.clone())); Dynamic::from_map(map) }) .collect() }) } // ============ Result Builders ============ #[tracing::instrument(skip_all)] pub fn task_result(items: rhai::Array) -> Dynamic { let mut result = Map::new(); result.insert("entity_type".into(), Dynamic::from("task")); result.insert("items".into(), Dynamic::from(items)); result.insert("warnings".into(), Dynamic::from(rhai::Array::new())); Dynamic::from_map(result) } #[tracing::instrument(skip_all)] pub fn project_result(items: rhai::Array) -> Dynamic { let mut result = Map::new(); result.insert("entity_type".into(), Dynamic::from("project")); result.insert("items".into(), Dynamic::from(items)); result.insert("warnings".into(), Dynamic::from(rhai::Array::new())); Dynamic::from_map(result) } #[tracing::instrument(skip_all)] pub fn event_result(items: rhai::Array) -> Dynamic { let mut result = Map::new(); result.insert("entity_type".into(), Dynamic::from("event")); result.insert("items".into(), Dynamic::from(items)); result.insert("warnings".into(), Dynamic::from(rhai::Array::new())); Dynamic::from_map(result) } } fn json_to_dynamic(value: serde_json::Value) -> Dynamic { match value { serde_json::Value::Null => Dynamic::UNIT, serde_json::Value::Bool(b) => Dynamic::from(b), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { Dynamic::from(i) } else if let Some(f) = n.as_f64() { Dynamic::from(f) } else { Dynamic::UNIT } } serde_json::Value::String(s) => Dynamic::from(s), serde_json::Value::Array(arr) => { Dynamic::from(arr.into_iter().map(json_to_dynamic).collect::()) } serde_json::Value::Object(obj) => { let mut map = Map::new(); for (k, v) in obj { map.insert(k.into(), json_to_dynamic(v)); } Dynamic::from_map(map) } } } /// Creates the goingson:: module for Rhai. #[tracing::instrument(skip_all)] pub fn create_goingson_module() -> Module { exported_module!(goingson_api) } /// Converts a Rhai Dynamic parse result to our ImportParseResult type. #[tracing::instrument(skip_all)] pub fn dynamic_to_import_result( value: Dynamic, plugin_id: &str, ) -> Result { let map = value.try_cast::().ok_or_else(|| { crate::error::PluginError::invalid_return(plugin_id, "parse() must return a map") })?; let entity_type_str = map .get("entity_type") .and_then(|v| v.clone().into_string().ok()) .ok_or_else(|| { crate::error::PluginError::invalid_return(plugin_id, "missing entity_type field") })?; let entity_type = match entity_type_str.as_str() { "task" => ImportEntityType::Task, "project" => ImportEntityType::Project, "event" => ImportEntityType::Event, other => { return Err(crate::error::PluginError::invalid_return( plugin_id, format!("unknown entity_type: {}", other), )) } }; let items_array = map .get("items") .and_then(|v| v.clone().try_cast::()) .ok_or_else(|| { crate::error::PluginError::invalid_return(plugin_id, "missing items array") })?; let mut items = Vec::new(); for (idx, item) in items_array.into_iter().enumerate() { let item_map = item.try_cast::().ok_or_else(|| { crate::error::PluginError::invalid_return( plugin_id, format!("item {} is not a map", idx), ) })?; let import_item = map_to_import_item(&item_map, idx, &entity_type)?; items.push(import_item); } let warnings = map .get("warnings") .and_then(|v| v.clone().try_cast::()) .map(|arr| { arr.into_iter() .filter_map(|v| v.into_string().ok()) .collect() }) .unwrap_or_default(); Ok(ImportParseResult { entity_type, items, warnings, }) } fn map_to_import_item( map: &Map, source_index: usize, entity_type: &ImportEntityType, ) -> Result { let data = match entity_type { ImportEntityType::Task => { let description = map .get("description") .and_then(|v| v.clone().into_string().ok()) .unwrap_or_default(); ImportItemData::Task(ImportTaskData { description, due: map.get("due").and_then(|v| v.clone().into_string().ok()), priority: map.get("priority").and_then(|v| v.clone().into_string().ok()), status: map.get("status").and_then(|v| v.clone().into_string().ok()), project_name: map.get("project_name").and_then(|v| v.clone().into_string().ok()), tags: map.get("tags").and_then(|v| { v.clone().try_cast::().map(|arr| { arr.into_iter() .filter_map(|item| item.into_string().ok()) .collect() }) }), notes: map.get("notes").and_then(|v| v.clone().into_string().ok()), }) } ImportEntityType::Project => { let name = map .get("name") .and_then(|v| v.clone().into_string().ok()) .unwrap_or_default(); ImportItemData::Project(ImportProjectData { name, description: map.get("description").and_then(|v| v.clone().into_string().ok()), project_type: map.get("project_type").and_then(|v| v.clone().into_string().ok()), status: map.get("status").and_then(|v| v.clone().into_string().ok()), }) } ImportEntityType::Event => { let title = map .get("title") .and_then(|v| v.clone().into_string().ok()) .unwrap_or_default(); let start = map .get("start") .and_then(|v| v.clone().into_string().ok()) .unwrap_or_default(); ImportItemData::Event(ImportEventData { title, start, end: map.get("end").and_then(|v| v.clone().into_string().ok()), location: map.get("location").and_then(|v| v.clone().into_string().ok()), description: map.get("description").and_then(|v| v.clone().into_string().ok()), project_name: map.get("project_name").and_then(|v| v.clone().into_string().ok()), }) } }; Ok(ImportItem { source_index, data, has_errors: false, errors: Vec::new(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_logging() { PluginApiContext::clear(); CONTEXT.with(|c| { c.borrow_mut().logs.push(("info".to_string(), "Test message".to_string())); c.borrow_mut().logs.push(("warn".to_string(), "Warning".to_string())); c.borrow_mut().logs.push(("error".to_string(), "Error".to_string())); }); let logs = PluginApi::get_logs(); assert_eq!(logs.len(), 3); assert_eq!(logs[0], ("info".to_string(), "Test message".to_string())); PluginApiContext::clear(); } // ============ Helper ============ /// Builds a Rhai Map from string key-value pairs for concise test setup. fn make_map(entries: Vec<(&str, Dynamic)>) -> Map { let mut map = Map::new(); for (k, v) in entries { map.insert(k.into(), v); } map } /// Wraps items in a result map that `dynamic_to_import_result` expects. fn wrap_result(entity_type: &str, items: rhai::Array) -> Dynamic { let mut result = Map::new(); result.insert("entity_type".into(), Dynamic::from(entity_type.to_string())); result.insert("items".into(), Dynamic::from(items)); result.insert("warnings".into(), Dynamic::from(rhai::Array::new())); Dynamic::from_map(result) } // ============ Task Field Mapping ============ #[test] fn task_all_fields_mapped() { let tags = vec![ Dynamic::from("bug".to_string()), Dynamic::from("urgent".to_string()), ]; let task_map = make_map(vec![ ("description", Dynamic::from("Fix the login bug".to_string())), ("due", Dynamic::from("2025-06-15".to_string())), ("priority", Dynamic::from("High".to_string())), ("status", Dynamic::from("InProgress".to_string())), ("project_name", Dynamic::from("Auth Rewrite".to_string())), ("tags", Dynamic::from(tags)), ("notes", Dynamic::from("Blocks release".to_string())), ]); let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); assert_eq!(parsed.entity_type, ImportEntityType::Task); assert_eq!(parsed.items.len(), 1); let item = &parsed.items[0]; assert_eq!(item.source_index, 0); assert!(!item.has_errors); match &item.data { ImportItemData::Task(t) => { assert_eq!(t.description, "Fix the login bug"); assert_eq!(t.due.as_deref(), Some("2025-06-15")); assert_eq!(t.priority.as_deref(), Some("High")); assert_eq!(t.status.as_deref(), Some("InProgress")); assert_eq!(t.project_name.as_deref(), Some("Auth Rewrite")); assert_eq!(t.tags.as_ref().unwrap(), &["bug", "urgent"]); assert_eq!(t.notes.as_deref(), Some("Blocks release")); } other => panic!("Expected Task, got {:?}", other), } } #[test] fn task_minimal_fields() { let task_map = make_map(vec![ ("description", Dynamic::from("Simple task".to_string())), ]); let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Task(t) => { assert_eq!(t.description, "Simple task"); assert!(t.due.is_none()); assert!(t.priority.is_none()); assert!(t.status.is_none()); assert!(t.project_name.is_none()); assert!(t.tags.is_none()); assert!(t.notes.is_none()); } other => panic!("Expected Task, got {:?}", other), } } #[test] fn task_empty_description_defaults() { // No description key at all -- should default to empty string. let task_map = make_map(vec![ ("priority", Dynamic::from("Low".to_string())), ]); let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Task(t) => { assert_eq!(t.description, ""); assert_eq!(t.priority.as_deref(), Some("Low")); } other => panic!("Expected Task, got {:?}", other), } } #[test] fn task_empty_tags_array() { let task_map = make_map(vec![ ("description", Dynamic::from("Tagged task".to_string())), ("tags", Dynamic::from(rhai::Array::new())), ]); let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Task(t) => { assert_eq!(t.tags.as_ref().unwrap().len(), 0); } other => panic!("Expected Task, got {:?}", other), } } // ============ Project Field Mapping ============ #[test] fn project_all_fields_mapped() { let project_map = make_map(vec![ ("name", Dynamic::from("Auth Rewrite".to_string())), ("description", Dynamic::from("Rewrite auth subsystem".to_string())), ("project_type", Dynamic::from("software".to_string())), ("status", Dynamic::from("Active".to_string())), ]); let result_dyn = wrap_result("project", vec![Dynamic::from_map(project_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); assert_eq!(parsed.entity_type, ImportEntityType::Project); assert_eq!(parsed.items.len(), 1); match &parsed.items[0].data { ImportItemData::Project(p) => { assert_eq!(p.name, "Auth Rewrite"); assert_eq!(p.description.as_deref(), Some("Rewrite auth subsystem")); assert_eq!(p.project_type.as_deref(), Some("software")); assert_eq!(p.status.as_deref(), Some("Active")); } other => panic!("Expected Project, got {:?}", other), } } #[test] fn project_minimal_fields() { let project_map = make_map(vec![ ("name", Dynamic::from("Bare Project".to_string())), ]); let result_dyn = wrap_result("project", vec![Dynamic::from_map(project_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Project(p) => { assert_eq!(p.name, "Bare Project"); assert!(p.description.is_none()); assert!(p.project_type.is_none()); assert!(p.status.is_none()); } other => panic!("Expected Project, got {:?}", other), } } #[test] fn project_missing_name_defaults_empty() { let project_map = make_map(vec![ ("status", Dynamic::from("Archived".to_string())), ]); let result_dyn = wrap_result("project", vec![Dynamic::from_map(project_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Project(p) => { assert_eq!(p.name, ""); assert_eq!(p.status.as_deref(), Some("Archived")); } other => panic!("Expected Project, got {:?}", other), } } // ============ Event Field Mapping ============ #[test] fn event_all_fields_mapped() { let event_map = make_map(vec![ ("title", Dynamic::from("Sprint Review".to_string())), ("start", Dynamic::from("2025-06-20T14:00:00".to_string())), ("end", Dynamic::from("2025-06-20T15:00:00".to_string())), ("location", Dynamic::from("Room 42".to_string())), ("description", Dynamic::from("Review sprint goals".to_string())), ("project_name", Dynamic::from("Auth Rewrite".to_string())), ]); let result_dyn = wrap_result("event", vec![Dynamic::from_map(event_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); assert_eq!(parsed.entity_type, ImportEntityType::Event); assert_eq!(parsed.items.len(), 1); match &parsed.items[0].data { ImportItemData::Event(e) => { assert_eq!(e.title, "Sprint Review"); assert_eq!(e.start, "2025-06-20T14:00:00"); assert_eq!(e.end.as_deref(), Some("2025-06-20T15:00:00")); assert_eq!(e.location.as_deref(), Some("Room 42")); assert_eq!(e.description.as_deref(), Some("Review sprint goals")); assert_eq!(e.project_name.as_deref(), Some("Auth Rewrite")); } other => panic!("Expected Event, got {:?}", other), } } #[test] fn event_minimal_fields() { let event_map = make_map(vec![ ("title", Dynamic::from("Standup".to_string())), ("start", Dynamic::from("2025-06-20T09:00:00".to_string())), ]); let result_dyn = wrap_result("event", vec![Dynamic::from_map(event_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Event(e) => { assert_eq!(e.title, "Standup"); assert_eq!(e.start, "2025-06-20T09:00:00"); assert!(e.end.is_none()); assert!(e.location.is_none()); assert!(e.description.is_none()); assert!(e.project_name.is_none()); } other => panic!("Expected Event, got {:?}", other), } } #[test] fn event_missing_required_defaults_empty() { // Missing title and start -- both default to empty strings. let event_map = make_map(vec![ ("location", Dynamic::from("Remote".to_string())), ]); let result_dyn = wrap_result("event", vec![Dynamic::from_map(event_map)]); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); match &parsed.items[0].data { ImportItemData::Event(e) => { assert_eq!(e.title, ""); assert_eq!(e.start, ""); assert_eq!(e.location.as_deref(), Some("Remote")); } other => panic!("Expected Event, got {:?}", other), } } // ============ Multiple Items ============ #[test] fn multiple_items_preserve_source_index() { let items: rhai::Array = (0..3) .map(|i| { let m = make_map(vec![ ("description", Dynamic::from(format!("Task {}", i))), ]); Dynamic::from_map(m) }) .collect(); let result_dyn = wrap_result("task", items); let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap(); assert_eq!(parsed.items.len(), 3); for (idx, item) in parsed.items.iter().enumerate() { assert_eq!(item.source_index, idx); match &item.data { ImportItemData::Task(t) => { assert_eq!(t.description, format!("Task {}", idx)); } other => panic!("Expected Task, got {:?}", other), } } } // ============ Warnings ============ #[test] fn warnings_are_collected() { let mut result = Map::new(); result.insert("entity_type".into(), Dynamic::from("task".to_string())); result.insert("items".into(), Dynamic::from(rhai::Array::new())); let warnings: rhai::Array = vec![ Dynamic::from("Skipped row 3".to_string()), Dynamic::from("Unknown column: foo".to_string()), ]; result.insert("warnings".into(), Dynamic::from(warnings)); let parsed = dynamic_to_import_result(Dynamic::from_map(result), "test-plugin").unwrap(); assert_eq!(parsed.warnings.len(), 2); assert_eq!(parsed.warnings[0], "Skipped row 3"); assert_eq!(parsed.warnings[1], "Unknown column: foo"); } // ============ Error Cases ============ #[test] fn missing_entity_type_is_error() { let mut result = Map::new(); result.insert("items".into(), Dynamic::from(rhai::Array::new())); let err = dynamic_to_import_result(Dynamic::from_map(result), "test-plugin"); assert!(err.is_err()); } #[test] fn unknown_entity_type_is_error() { let result_dyn = wrap_result("contact", rhai::Array::new()); let err = dynamic_to_import_result(result_dyn, "test-plugin"); assert!(err.is_err()); } #[test] fn non_map_item_is_error() { let items: rhai::Array = vec![Dynamic::from("not a map".to_string())]; let result_dyn = wrap_result("task", items); let err = dynamic_to_import_result(result_dyn, "test-plugin"); assert!(err.is_err()); } #[test] fn non_map_top_level_is_error() { let err = dynamic_to_import_result(Dynamic::from(42_i64), "test-plugin"); assert!(err.is_err()); } // ============ json_to_dynamic ============ #[test] fn json_to_dynamic_primitives() { assert_eq!(json_to_dynamic(serde_json::Value::Null).type_name(), "()"); assert!(json_to_dynamic(serde_json::Value::Bool(true)).as_bool().unwrap()); assert_eq!(json_to_dynamic(serde_json::json!(42)).as_int().unwrap(), 42); assert_eq!(json_to_dynamic(serde_json::json!(2.72)).as_float().unwrap(), 2.72); assert_eq!( json_to_dynamic(serde_json::json!("hello")).into_string().unwrap(), "hello" ); } #[test] fn json_to_dynamic_array() { let val = serde_json::json!([1, "two", true]); let dyn_val = json_to_dynamic(val); let arr = dyn_val.try_cast::().unwrap(); assert_eq!(arr.len(), 3); assert_eq!(arr[0].as_int().unwrap(), 1); assert_eq!(arr[1].clone().into_string().unwrap(), "two"); assert!(arr[2].as_bool().unwrap()); } #[test] fn json_to_dynamic_object() { let val = serde_json::json!({"name": "test", "count": 5}); let dyn_val = json_to_dynamic(val); let map = dyn_val.try_cast::().unwrap(); assert_eq!(map.get("name").unwrap().clone().into_string().unwrap(), "test"); assert_eq!(map.get("count").unwrap().as_int().unwrap(), 5); } #[test] fn json_to_dynamic_nested() { let val = serde_json::json!({"items": [{"id": 1}, {"id": 2}]}); let dyn_val = json_to_dynamic(val); let map = dyn_val.try_cast::().unwrap(); let items = map.get("items").unwrap().clone().try_cast::().unwrap(); assert_eq!(items.len(), 2); let first = items[0].clone().try_cast::().unwrap(); assert_eq!(first.get("id").unwrap().as_int().unwrap(), 1); } // ============ Result Builders ============ #[test] fn task_result_builder() { let items: rhai::Array = vec![Dynamic::from("placeholder".to_string())]; let result = goingson_api::task_result(items); let map = result.try_cast::().unwrap(); assert_eq!(map.get("entity_type").unwrap().clone().into_string().unwrap(), "task"); let items_arr = map.get("items").unwrap().clone().try_cast::().unwrap(); assert_eq!(items_arr.len(), 1); let warnings = map.get("warnings").unwrap().clone().try_cast::().unwrap(); assert!(warnings.is_empty()); } #[test] fn project_result_builder() { let result = goingson_api::project_result(rhai::Array::new()); let map = result.try_cast::().unwrap(); assert_eq!(map.get("entity_type").unwrap().clone().into_string().unwrap(), "project"); } #[test] fn event_result_builder() { let result = goingson_api::event_result(rhai::Array::new()); let map = result.try_cast::().unwrap(); assert_eq!(map.get("entity_type").unwrap().clone().into_string().unwrap(), "event"); } // ============ Context Management ============ #[test] fn context_set_and_get() { PluginApiContext::clear(); let ctx = PluginApiContext { import_file_path: Some("/tmp/test.csv".to_string()), can_read_files: true, can_write_db: false, logs: vec![("info".to_string(), "hi".to_string())], progress: Some((5, 10, "halfway".to_string())), projects: vec![("p1".to_string(), "Project One".to_string())], }; PluginApiContext::set(ctx); let got = PluginApiContext::get(); assert_eq!(got.import_file_path.as_deref(), Some("/tmp/test.csv")); assert!(got.can_read_files); assert!(!got.can_write_db); assert_eq!(got.logs.len(), 1); assert_eq!(got.progress, Some((5, 10, "halfway".to_string()))); assert_eq!(got.projects.len(), 1); PluginApiContext::clear(); } #[test] fn context_clear_resets() { PluginApiContext::set(PluginApiContext { can_read_files: true, ..Default::default() }); PluginApiContext::clear(); let got = PluginApiContext::get(); assert!(!got.can_read_files); assert!(got.import_file_path.is_none()); assert!(got.logs.is_empty()); assert!(got.progress.is_none()); assert!(got.projects.is_empty()); } // ============ Round-trip: result builder -> dynamic_to_import_result ============ #[test] fn task_result_builder_round_trip() { let task_map = make_map(vec![ ("description", Dynamic::from("Round-trip task".to_string())), ("priority", Dynamic::from("Medium".to_string())), ]); let result = goingson_api::task_result(vec![Dynamic::from_map(task_map)]); let parsed = dynamic_to_import_result(result, "roundtrip").unwrap(); assert_eq!(parsed.entity_type, ImportEntityType::Task); match &parsed.items[0].data { ImportItemData::Task(t) => { assert_eq!(t.description, "Round-trip task"); assert_eq!(t.priority.as_deref(), Some("Medium")); } other => panic!("Expected Task, got {:?}", other), } } #[test] fn project_result_builder_round_trip() { let project_map = make_map(vec![ ("name", Dynamic::from("Round-trip project".to_string())), ("description", Dynamic::from("Testing".to_string())), ]); let result = goingson_api::project_result(vec![Dynamic::from_map(project_map)]); let parsed = dynamic_to_import_result(result, "roundtrip").unwrap(); assert_eq!(parsed.entity_type, ImportEntityType::Project); match &parsed.items[0].data { ImportItemData::Project(p) => { assert_eq!(p.name, "Round-trip project"); assert_eq!(p.description.as_deref(), Some("Testing")); } other => panic!("Expected Project, got {:?}", other), } } #[test] fn event_result_builder_round_trip() { let event_map = make_map(vec![ ("title", Dynamic::from("Round-trip event".to_string())), ("start", Dynamic::from("2025-12-25T10:00:00".to_string())), ("end", Dynamic::from("2025-12-25T11:00:00".to_string())), ("location", Dynamic::from("North Pole".to_string())), ]); let result = goingson_api::event_result(vec![Dynamic::from_map(event_map)]); let parsed = dynamic_to_import_result(result, "roundtrip").unwrap(); assert_eq!(parsed.entity_type, ImportEntityType::Event); match &parsed.items[0].data { ImportItemData::Event(e) => { assert_eq!(e.title, "Round-trip event"); assert_eq!(e.start, "2025-12-25T10:00:00"); assert_eq!(e.end.as_deref(), Some("2025-12-25T11:00:00")); assert_eq!(e.location.as_deref(), Some("North Pole")); } other => panic!("Expected Event, got {:?}", other), } } }