Skip to main content

max / goingson

39.3 KB · 1107 lines History Blame Raw
1 //! Rhai API module for plugins.
2 //!
3 //! Provides the `goingson::` namespace with functions for:
4 //! - Entity creation (tasks, projects, events)
5 //! - File I/O (sandboxed)
6 //! - CSV/JSON parsing
7 //! - Logging and progress reporting
8 //! - Date parsing
9
10 use rhai::plugin::*;
11 use rhai::{Dynamic, EvalAltResult, Map, Module};
12 use std::cell::RefCell;
13
14 use goingson_core::{
15 ImportEntityType, ImportItem, ImportItemData, ImportParseResult, ImportTaskData,
16 ImportProjectData, ImportEventData,
17 };
18
19 // Thread-local context for plugin execution.
20 thread_local! {
21 static CONTEXT: RefCell<PluginApiContext> = RefCell::new(PluginApiContext::default());
22 }
23
24 /// Runtime context for plugin API calls.
25 #[derive(Default)]
26 pub struct PluginApiContext {
27 /// File path being imported (for sandboxed file access).
28 pub import_file_path: Option<String>,
29 /// Whether file_read capability is granted.
30 pub can_read_files: bool,
31 /// Whether database_write capability is granted.
32 pub can_write_db: bool,
33 /// Collected log entries.
34 pub logs: Vec<(String, String)>, // (level, message)
35 /// Current progress.
36 pub progress: Option<(usize, usize, String)>, // (current, total, message)
37 /// Projects cache for lookup.
38 pub projects: Vec<(String, String)>, // (id, name)
39 }
40
41 impl PluginApiContext {
42 /// Sets the context for the current thread.
43 #[tracing::instrument(skip_all)]
44 pub fn set(ctx: PluginApiContext) {
45 CONTEXT.with(|c| *c.borrow_mut() = ctx);
46 }
47
48 /// Gets a copy of the current context (used in tests).
49 #[cfg(test)]
50 pub fn get() -> PluginApiContext {
51 CONTEXT.with(|c| {
52 let ctx = c.borrow();
53 PluginApiContext {
54 import_file_path: ctx.import_file_path.clone(),
55 can_read_files: ctx.can_read_files,
56 can_write_db: ctx.can_write_db,
57 logs: ctx.logs.clone(),
58 progress: ctx.progress.clone(),
59 projects: ctx.projects.clone(),
60 }
61 })
62 }
63
64 /// Clears the context.
65 #[tracing::instrument(skip_all)]
66 pub fn clear() {
67 CONTEXT.with(|c| *c.borrow_mut() = PluginApiContext::default());
68 }
69 }
70
71 /// API handle for plugins to interact with GoingsOn.
72 pub struct PluginApi;
73
74 impl PluginApi {
75 /// Sets the import file path for sandboxed file access.
76 #[tracing::instrument(skip_all)]
77 pub fn set_import_file(path: &str) {
78 CONTEXT.with(|c| {
79 c.borrow_mut().import_file_path = Some(path.to_string());
80 });
81 }
82
83 /// Sets the capabilities for the current execution.
84 #[tracing::instrument(skip_all)]
85 pub fn set_capabilities(file_read: bool, database_write: bool) {
86 CONTEXT.with(|c| {
87 let mut ctx = c.borrow_mut();
88 ctx.can_read_files = file_read;
89 ctx.can_write_db = database_write;
90 });
91 }
92
93 /// Sets available projects for lookup.
94 #[tracing::instrument(skip_all)]
95 pub fn set_projects(projects: Vec<(String, String)>) {
96 CONTEXT.with(|c| {
97 c.borrow_mut().projects = projects;
98 });
99 }
100
101 /// Gets collected log entries.
102 #[tracing::instrument(skip_all)]
103 pub fn get_logs() -> Vec<(String, String)> {
104 CONTEXT.with(|c| c.borrow().logs.clone())
105 }
106
107 /// Gets current progress (used by plugin host for progress reporting).
108 #[tracing::instrument(skip_all)]
109 pub fn get_progress() -> Option<(usize, usize, String)> {
110 CONTEXT.with(|c| c.borrow().progress.clone())
111 }
112 }
113
114 /// The goingson module exported to Rhai
115 #[export_module]
116 mod goingson_api {
117 use super::*;
118
119 // ============ File I/O Functions ============
120
121 #[rhai_fn(return_raw)]
122 #[tracing::instrument(skip_all)]
123 pub fn read_file(path: &str) -> Result<String, Box<EvalAltResult>> {
124 CONTEXT.with(|c| {
125 let ctx = c.borrow();
126
127 // Check capability
128 if !ctx.can_read_files {
129 return Err("Permission denied: file_read capability not granted".into());
130 }
131
132 // Check if path matches the import file (sandboxing via canonical path comparison)
133 if let Some(ref allowed) = ctx.import_file_path {
134 let allowed_canonical = std::fs::canonicalize(allowed)
135 .map_err(|e| format!("Cannot resolve import file path: {}", e))?;
136 let request_canonical = std::fs::canonicalize(path)
137 .map_err(|e| format!("Cannot resolve requested path: {}", e))?;
138
139 // Allow reading the exact import file (canonical comparison prevents traversal)
140 if allowed_canonical != request_canonical {
141 return Err(format!(
142 "Permission denied: can only read import file '{}'",
143 allowed
144 ).into());
145 }
146 } else {
147 return Err("No import file set for this execution".into());
148 }
149
150 // Check file size before reading to prevent OOM on large files
151 let metadata = std::fs::metadata(path)
152 .map_err(|e| -> Box<EvalAltResult> { format!("Failed to stat file '{}': {}", path, e).into() })?;
153 let max_size = 10 * 1024 * 1024; // 10 MB
154 if metadata.len() > max_size {
155 return Err(format!(
156 "File '{}' is {} bytes, exceeding {} byte limit",
157 path, metadata.len(), max_size
158 ).into());
159 }
160
161 std::fs::read_to_string(path)
162 .map_err(|e| format!("Failed to read file '{}': {}", path, e).into())
163 })
164 }
165
166 #[rhai_fn(return_raw)]
167 #[tracing::instrument(skip_all)]
168 pub fn parse_csv(content: &str, options: Map) -> Result<rhai::Array, Box<EvalAltResult>> {
169 // Excel-exported CSVs start with a BOM that breaks header detection
170 let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
171
172 let has_header = options
173 .get("has_header")
174 .and_then(|v| v.as_bool().ok())
175 .unwrap_or(true);
176
177 let delimiter = options
178 .get("delimiter")
179 .and_then(|v| v.clone().into_string().ok())
180 .and_then(|s| s.chars().next())
181 .unwrap_or(',');
182
183 let mut reader = csv::ReaderBuilder::new()
184 .has_headers(has_header)
185 .delimiter(delimiter as u8)
186 .flexible(true)
187 .from_reader(content.as_bytes());
188
189 // Single-pass: determine headers, then iterate records from the same reader.
190 // For has_header=true, headers() consumes the first row and caches it.
191 // For has_header=false, peek the first record for column count, then
192 // process it as data along with the rest.
193 let (headers, first_record) = if has_header {
194 let hdrs: Vec<String> = reader
195 .headers()
196 .map_err(|e| -> Box<EvalAltResult> { format!("Failed to read CSV headers: {}", e).into() })?
197 .iter()
198 .map(|s| s.to_string())
199 .collect();
200 (hdrs, None)
201 } else {
202 // Read first record to determine column count
203 let first = reader.records().next();
204 match first {
205 Some(Ok(record)) => {
206 let hdrs: Vec<String> = (0..record.len()).map(|i| format!("col_{}", i)).collect();
207 (hdrs, Some(record))
208 }
209 _ => (Vec::new(), None),
210 }
211 };
212
213 let mut rows = rhai::Array::new();
214
215 // Process the first record if we consumed it for column detection
216 let records_iter: Box<dyn Iterator<Item = csv::Result<csv::StringRecord>>> =
217 if let Some(first) = first_record {
218 Box::new(std::iter::once(Ok(first)).chain(reader.records()))
219 } else {
220 Box::new(reader.records())
221 };
222
223 for result in records_iter {
224 let record = result.map_err(|e| -> Box<EvalAltResult> { format!("CSV parse error: {}", e).into() })?;
225
226 let mut row_map = Map::new();
227 for (i, field) in record.iter().enumerate() {
228 let key = headers.get(i).cloned().unwrap_or_else(|| format!("col_{}", i));
229 row_map.insert(key.into(), Dynamic::from(field.to_string()));
230 }
231
232 rows.push(Dynamic::from_map(row_map));
233 }
234
235 Ok(rows)
236 }
237
238 #[rhai_fn(return_raw)]
239 #[tracing::instrument(skip_all)]
240 pub fn parse_json(content: &str) -> Result<Dynamic, Box<EvalAltResult>> {
241 serde_json::from_str::<serde_json::Value>(content)
242 .map(json_to_dynamic)
243 .map_err(|e| format!("JSON parse error: {}", e).into())
244 }
245
246 // ============ Logging Functions ============
247
248 #[tracing::instrument(skip_all)]
249 pub fn log_info(message: &str) {
250 CONTEXT.with(|c| {
251 c.borrow_mut().logs.push(("info".to_string(), message.to_string()));
252 });
253 }
254
255 #[tracing::instrument(skip_all)]
256 pub fn log_warn(message: &str) {
257 CONTEXT.with(|c| {
258 c.borrow_mut().logs.push(("warn".to_string(), message.to_string()));
259 });
260 }
261
262 #[tracing::instrument(skip_all)]
263 pub fn log_error(message: &str) {
264 CONTEXT.with(|c| {
265 c.borrow_mut().logs.push(("error".to_string(), message.to_string()));
266 });
267 }
268
269 // ============ Progress Functions ============
270
271 #[tracing::instrument(skip_all)]
272 pub fn report_progress(current: i64, total: i64, message: &str) {
273 CONTEXT.with(|c| {
274 c.borrow_mut().progress = Some((current.max(0) as usize, total.max(0) as usize, message.to_string()));
275 });
276 }
277
278 // ============ Date Parsing ============
279
280 #[rhai_fn(return_raw)]
281 #[tracing::instrument(skip_all)]
282 pub fn parse_date(input: &str) -> Result<String, Box<EvalAltResult>> {
283 // Try various common date formats
284 let formats = [
285 "%Y-%m-%d",
286 "%Y-%m-%dT%H:%M:%S",
287 "%Y-%m-%dT%H:%M:%SZ",
288 "%Y-%m-%dT%H:%M:%S%.fZ",
289 "%Y/%m/%d",
290 "%m/%d/%Y",
291 "%d/%m/%Y",
292 "%d-%m-%Y",
293 "%B %d, %Y",
294 "%b %d, %Y",
295 ];
296
297 let input = input.trim();
298
299 // If empty, return empty (optional field)
300 if input.is_empty() {
301 return Ok(String::new());
302 }
303
304 for format in &formats {
305 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(input, format) {
306 return Ok(dt.format("%Y-%m-%dT%H:%M:%S").to_string());
307 }
308 if let Ok(d) = chrono::NaiveDate::parse_from_str(input, format) {
309 return Ok(d.format("%Y-%m-%d").to_string());
310 }
311 }
312
313 // Try parsing as ISO 8601
314 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(input) {
315 return Ok(dt.format("%Y-%m-%dT%H:%M:%S").to_string());
316 }
317
318 Err(format!("Could not parse date: '{}'", input).into())
319 }
320
321 // ============ Project Lookup ============
322
323 #[tracing::instrument(skip_all)]
324 pub fn find_project_by_name(name: &str) -> Dynamic {
325 CONTEXT.with(|c| {
326 let ctx = c.borrow();
327 let name_lower = name.to_lowercase();
328
329 for (id, project_name) in &ctx.projects {
330 if project_name.to_lowercase() == name_lower {
331 let mut map = Map::new();
332 map.insert("id".into(), Dynamic::from(id.clone()));
333 map.insert("name".into(), Dynamic::from(project_name.clone()));
334 return Dynamic::from_map(map);
335 }
336 }
337
338 Dynamic::UNIT
339 })
340 }
341
342 #[tracing::instrument(skip_all)]
343 pub fn list_projects() -> rhai::Array {
344 CONTEXT.with(|c| {
345 let ctx = c.borrow();
346 ctx.projects
347 .iter()
348 .map(|(id, name)| {
349 let mut map = Map::new();
350 map.insert("id".into(), Dynamic::from(id.clone()));
351 map.insert("name".into(), Dynamic::from(name.clone()));
352 Dynamic::from_map(map)
353 })
354 .collect()
355 })
356 }
357
358 // ============ Result Builders ============
359
360 #[tracing::instrument(skip_all)]
361 pub fn task_result(items: rhai::Array) -> Dynamic {
362 let mut result = Map::new();
363 result.insert("entity_type".into(), Dynamic::from("task"));
364 result.insert("items".into(), Dynamic::from(items));
365 result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
366 Dynamic::from_map(result)
367 }
368
369 #[tracing::instrument(skip_all)]
370 pub fn project_result(items: rhai::Array) -> Dynamic {
371 let mut result = Map::new();
372 result.insert("entity_type".into(), Dynamic::from("project"));
373 result.insert("items".into(), Dynamic::from(items));
374 result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
375 Dynamic::from_map(result)
376 }
377
378 #[tracing::instrument(skip_all)]
379 pub fn event_result(items: rhai::Array) -> Dynamic {
380 let mut result = Map::new();
381 result.insert("entity_type".into(), Dynamic::from("event"));
382 result.insert("items".into(), Dynamic::from(items));
383 result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
384 Dynamic::from_map(result)
385 }
386 }
387
388 fn json_to_dynamic(value: serde_json::Value) -> Dynamic {
389 match value {
390 serde_json::Value::Null => Dynamic::UNIT,
391 serde_json::Value::Bool(b) => Dynamic::from(b),
392 serde_json::Value::Number(n) => {
393 if let Some(i) = n.as_i64() {
394 Dynamic::from(i)
395 } else if let Some(f) = n.as_f64() {
396 Dynamic::from(f)
397 } else {
398 Dynamic::UNIT
399 }
400 }
401 serde_json::Value::String(s) => Dynamic::from(s),
402 serde_json::Value::Array(arr) => {
403 Dynamic::from(arr.into_iter().map(json_to_dynamic).collect::<rhai::Array>())
404 }
405 serde_json::Value::Object(obj) => {
406 let mut map = Map::new();
407 for (k, v) in obj {
408 map.insert(k.into(), json_to_dynamic(v));
409 }
410 Dynamic::from_map(map)
411 }
412 }
413 }
414
415 /// Creates the goingson:: module for Rhai.
416 #[tracing::instrument(skip_all)]
417 pub fn create_goingson_module() -> Module {
418 exported_module!(goingson_api)
419 }
420
421 /// Converts a Rhai Dynamic parse result to our ImportParseResult type.
422 #[tracing::instrument(skip_all)]
423 pub fn dynamic_to_import_result(
424 value: Dynamic,
425 plugin_id: &str,
426 ) -> Result<ImportParseResult, crate::error::PluginError> {
427 let map = value.try_cast::<Map>().ok_or_else(|| {
428 crate::error::PluginError::invalid_return(plugin_id, "parse() must return a map")
429 })?;
430
431 let entity_type_str = map
432 .get("entity_type")
433 .and_then(|v| v.clone().into_string().ok())
434 .ok_or_else(|| {
435 crate::error::PluginError::invalid_return(plugin_id, "missing entity_type field")
436 })?;
437
438 let entity_type = match entity_type_str.as_str() {
439 "task" => ImportEntityType::Task,
440 "project" => ImportEntityType::Project,
441 "event" => ImportEntityType::Event,
442 other => {
443 return Err(crate::error::PluginError::invalid_return(
444 plugin_id,
445 format!("unknown entity_type: {}", other),
446 ))
447 }
448 };
449
450 let items_array = map
451 .get("items")
452 .and_then(|v| v.clone().try_cast::<rhai::Array>())
453 .ok_or_else(|| {
454 crate::error::PluginError::invalid_return(plugin_id, "missing items array")
455 })?;
456
457 let mut items = Vec::new();
458 for (idx, item) in items_array.into_iter().enumerate() {
459 let item_map = item.try_cast::<Map>().ok_or_else(|| {
460 crate::error::PluginError::invalid_return(
461 plugin_id,
462 format!("item {} is not a map", idx),
463 )
464 })?;
465
466 let import_item = map_to_import_item(&item_map, idx, &entity_type)?;
467 items.push(import_item);
468 }
469
470 let warnings = map
471 .get("warnings")
472 .and_then(|v| v.clone().try_cast::<rhai::Array>())
473 .map(|arr| {
474 arr.into_iter()
475 .filter_map(|v| v.into_string().ok())
476 .collect()
477 })
478 .unwrap_or_default();
479
480 Ok(ImportParseResult {
481 entity_type,
482 items,
483 warnings,
484 })
485 }
486
487 fn map_to_import_item(
488 map: &Map,
489 source_index: usize,
490 entity_type: &ImportEntityType,
491 ) -> Result<ImportItem, crate::error::PluginError> {
492 let data = match entity_type {
493 ImportEntityType::Task => {
494 let description = map
495 .get("description")
496 .and_then(|v| v.clone().into_string().ok())
497 .unwrap_or_default();
498
499 ImportItemData::Task(ImportTaskData {
500 description,
501 due: map.get("due").and_then(|v| v.clone().into_string().ok()),
502 priority: map.get("priority").and_then(|v| v.clone().into_string().ok()),
503 status: map.get("status").and_then(|v| v.clone().into_string().ok()),
504 project_name: map.get("project_name").and_then(|v| v.clone().into_string().ok()),
505 tags: map.get("tags").and_then(|v| {
506 v.clone().try_cast::<rhai::Array>().map(|arr| {
507 arr.into_iter()
508 .filter_map(|item| item.into_string().ok())
509 .collect()
510 })
511 }),
512 notes: map.get("notes").and_then(|v| v.clone().into_string().ok()),
513 })
514 }
515 ImportEntityType::Project => {
516 let name = map
517 .get("name")
518 .and_then(|v| v.clone().into_string().ok())
519 .unwrap_or_default();
520
521 ImportItemData::Project(ImportProjectData {
522 name,
523 description: map.get("description").and_then(|v| v.clone().into_string().ok()),
524 project_type: map.get("project_type").and_then(|v| v.clone().into_string().ok()),
525 status: map.get("status").and_then(|v| v.clone().into_string().ok()),
526 })
527 }
528 ImportEntityType::Event => {
529 let title = map
530 .get("title")
531 .and_then(|v| v.clone().into_string().ok())
532 .unwrap_or_default();
533 let start = map
534 .get("start")
535 .and_then(|v| v.clone().into_string().ok())
536 .unwrap_or_default();
537
538 ImportItemData::Event(ImportEventData {
539 title,
540 start,
541 end: map.get("end").and_then(|v| v.clone().into_string().ok()),
542 location: map.get("location").and_then(|v| v.clone().into_string().ok()),
543 description: map.get("description").and_then(|v| v.clone().into_string().ok()),
544 project_name: map.get("project_name").and_then(|v| v.clone().into_string().ok()),
545 })
546 }
547 };
548
549 Ok(ImportItem {
550 source_index,
551 data,
552 has_errors: false,
553 errors: Vec::new(),
554 })
555 }
556
557 #[cfg(test)]
558 mod tests {
559 use super::*;
560
561 #[test]
562 fn test_logging() {
563 PluginApiContext::clear();
564
565 CONTEXT.with(|c| {
566 c.borrow_mut().logs.push(("info".to_string(), "Test message".to_string()));
567 c.borrow_mut().logs.push(("warn".to_string(), "Warning".to_string()));
568 c.borrow_mut().logs.push(("error".to_string(), "Error".to_string()));
569 });
570
571 let logs = PluginApi::get_logs();
572 assert_eq!(logs.len(), 3);
573 assert_eq!(logs[0], ("info".to_string(), "Test message".to_string()));
574
575 PluginApiContext::clear();
576 }
577
578 // ============ Helper ============
579
580 /// Builds a Rhai Map from string key-value pairs for concise test setup.
581 fn make_map(entries: Vec<(&str, Dynamic)>) -> Map {
582 let mut map = Map::new();
583 for (k, v) in entries {
584 map.insert(k.into(), v);
585 }
586 map
587 }
588
589 /// Wraps items in a result map that `dynamic_to_import_result` expects.
590 fn wrap_result(entity_type: &str, items: rhai::Array) -> Dynamic {
591 let mut result = Map::new();
592 result.insert("entity_type".into(), Dynamic::from(entity_type.to_string()));
593 result.insert("items".into(), Dynamic::from(items));
594 result.insert("warnings".into(), Dynamic::from(rhai::Array::new()));
595 Dynamic::from_map(result)
596 }
597
598 // ============ Task Field Mapping ============
599
600 #[test]
601 fn task_all_fields_mapped() {
602 let tags = vec![
603 Dynamic::from("bug".to_string()),
604 Dynamic::from("urgent".to_string()),
605 ];
606
607 let task_map = make_map(vec![
608 ("description", Dynamic::from("Fix the login bug".to_string())),
609 ("due", Dynamic::from("2025-06-15".to_string())),
610 ("priority", Dynamic::from("High".to_string())),
611 ("status", Dynamic::from("InProgress".to_string())),
612 ("project_name", Dynamic::from("Auth Rewrite".to_string())),
613 ("tags", Dynamic::from(tags)),
614 ("notes", Dynamic::from("Blocks release".to_string())),
615 ]);
616
617 let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]);
618 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
619
620 assert_eq!(parsed.entity_type, ImportEntityType::Task);
621 assert_eq!(parsed.items.len(), 1);
622
623 let item = &parsed.items[0];
624 assert_eq!(item.source_index, 0);
625 assert!(!item.has_errors);
626
627 match &item.data {
628 ImportItemData::Task(t) => {
629 assert_eq!(t.description, "Fix the login bug");
630 assert_eq!(t.due.as_deref(), Some("2025-06-15"));
631 assert_eq!(t.priority.as_deref(), Some("High"));
632 assert_eq!(t.status.as_deref(), Some("InProgress"));
633 assert_eq!(t.project_name.as_deref(), Some("Auth Rewrite"));
634 assert_eq!(t.tags.as_ref().unwrap(), &["bug", "urgent"]);
635 assert_eq!(t.notes.as_deref(), Some("Blocks release"));
636 }
637 other => panic!("Expected Task, got {:?}", other),
638 }
639 }
640
641 #[test]
642 fn task_minimal_fields() {
643 let task_map = make_map(vec![
644 ("description", Dynamic::from("Simple task".to_string())),
645 ]);
646
647 let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]);
648 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
649
650 match &parsed.items[0].data {
651 ImportItemData::Task(t) => {
652 assert_eq!(t.description, "Simple task");
653 assert!(t.due.is_none());
654 assert!(t.priority.is_none());
655 assert!(t.status.is_none());
656 assert!(t.project_name.is_none());
657 assert!(t.tags.is_none());
658 assert!(t.notes.is_none());
659 }
660 other => panic!("Expected Task, got {:?}", other),
661 }
662 }
663
664 #[test]
665 fn task_empty_description_defaults() {
666 // No description key at all -- should default to empty string.
667 let task_map = make_map(vec![
668 ("priority", Dynamic::from("Low".to_string())),
669 ]);
670
671 let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]);
672 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
673
674 match &parsed.items[0].data {
675 ImportItemData::Task(t) => {
676 assert_eq!(t.description, "");
677 assert_eq!(t.priority.as_deref(), Some("Low"));
678 }
679 other => panic!("Expected Task, got {:?}", other),
680 }
681 }
682
683 #[test]
684 fn task_empty_tags_array() {
685 let task_map = make_map(vec![
686 ("description", Dynamic::from("Tagged task".to_string())),
687 ("tags", Dynamic::from(rhai::Array::new())),
688 ]);
689
690 let result_dyn = wrap_result("task", vec![Dynamic::from_map(task_map)]);
691 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
692
693 match &parsed.items[0].data {
694 ImportItemData::Task(t) => {
695 assert_eq!(t.tags.as_ref().unwrap().len(), 0);
696 }
697 other => panic!("Expected Task, got {:?}", other),
698 }
699 }
700
701 // ============ Project Field Mapping ============
702
703 #[test]
704 fn project_all_fields_mapped() {
705 let project_map = make_map(vec![
706 ("name", Dynamic::from("Auth Rewrite".to_string())),
707 ("description", Dynamic::from("Rewrite auth subsystem".to_string())),
708 ("project_type", Dynamic::from("software".to_string())),
709 ("status", Dynamic::from("Active".to_string())),
710 ]);
711
712 let result_dyn = wrap_result("project", vec![Dynamic::from_map(project_map)]);
713 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
714
715 assert_eq!(parsed.entity_type, ImportEntityType::Project);
716 assert_eq!(parsed.items.len(), 1);
717
718 match &parsed.items[0].data {
719 ImportItemData::Project(p) => {
720 assert_eq!(p.name, "Auth Rewrite");
721 assert_eq!(p.description.as_deref(), Some("Rewrite auth subsystem"));
722 assert_eq!(p.project_type.as_deref(), Some("software"));
723 assert_eq!(p.status.as_deref(), Some("Active"));
724 }
725 other => panic!("Expected Project, got {:?}", other),
726 }
727 }
728
729 #[test]
730 fn project_minimal_fields() {
731 let project_map = make_map(vec![
732 ("name", Dynamic::from("Bare Project".to_string())),
733 ]);
734
735 let result_dyn = wrap_result("project", vec![Dynamic::from_map(project_map)]);
736 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
737
738 match &parsed.items[0].data {
739 ImportItemData::Project(p) => {
740 assert_eq!(p.name, "Bare Project");
741 assert!(p.description.is_none());
742 assert!(p.project_type.is_none());
743 assert!(p.status.is_none());
744 }
745 other => panic!("Expected Project, got {:?}", other),
746 }
747 }
748
749 #[test]
750 fn project_missing_name_defaults_empty() {
751 let project_map = make_map(vec![
752 ("status", Dynamic::from("Archived".to_string())),
753 ]);
754
755 let result_dyn = wrap_result("project", vec![Dynamic::from_map(project_map)]);
756 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
757
758 match &parsed.items[0].data {
759 ImportItemData::Project(p) => {
760 assert_eq!(p.name, "");
761 assert_eq!(p.status.as_deref(), Some("Archived"));
762 }
763 other => panic!("Expected Project, got {:?}", other),
764 }
765 }
766
767 // ============ Event Field Mapping ============
768
769 #[test]
770 fn event_all_fields_mapped() {
771 let event_map = make_map(vec![
772 ("title", Dynamic::from("Sprint Review".to_string())),
773 ("start", Dynamic::from("2025-06-20T14:00:00".to_string())),
774 ("end", Dynamic::from("2025-06-20T15:00:00".to_string())),
775 ("location", Dynamic::from("Room 42".to_string())),
776 ("description", Dynamic::from("Review sprint goals".to_string())),
777 ("project_name", Dynamic::from("Auth Rewrite".to_string())),
778 ]);
779
780 let result_dyn = wrap_result("event", vec![Dynamic::from_map(event_map)]);
781 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
782
783 assert_eq!(parsed.entity_type, ImportEntityType::Event);
784 assert_eq!(parsed.items.len(), 1);
785
786 match &parsed.items[0].data {
787 ImportItemData::Event(e) => {
788 assert_eq!(e.title, "Sprint Review");
789 assert_eq!(e.start, "2025-06-20T14:00:00");
790 assert_eq!(e.end.as_deref(), Some("2025-06-20T15:00:00"));
791 assert_eq!(e.location.as_deref(), Some("Room 42"));
792 assert_eq!(e.description.as_deref(), Some("Review sprint goals"));
793 assert_eq!(e.project_name.as_deref(), Some("Auth Rewrite"));
794 }
795 other => panic!("Expected Event, got {:?}", other),
796 }
797 }
798
799 #[test]
800 fn event_minimal_fields() {
801 let event_map = make_map(vec![
802 ("title", Dynamic::from("Standup".to_string())),
803 ("start", Dynamic::from("2025-06-20T09:00:00".to_string())),
804 ]);
805
806 let result_dyn = wrap_result("event", vec![Dynamic::from_map(event_map)]);
807 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
808
809 match &parsed.items[0].data {
810 ImportItemData::Event(e) => {
811 assert_eq!(e.title, "Standup");
812 assert_eq!(e.start, "2025-06-20T09:00:00");
813 assert!(e.end.is_none());
814 assert!(e.location.is_none());
815 assert!(e.description.is_none());
816 assert!(e.project_name.is_none());
817 }
818 other => panic!("Expected Event, got {:?}", other),
819 }
820 }
821
822 #[test]
823 fn event_missing_required_defaults_empty() {
824 // Missing title and start -- both default to empty strings.
825 let event_map = make_map(vec![
826 ("location", Dynamic::from("Remote".to_string())),
827 ]);
828
829 let result_dyn = wrap_result("event", vec![Dynamic::from_map(event_map)]);
830 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
831
832 match &parsed.items[0].data {
833 ImportItemData::Event(e) => {
834 assert_eq!(e.title, "");
835 assert_eq!(e.start, "");
836 assert_eq!(e.location.as_deref(), Some("Remote"));
837 }
838 other => panic!("Expected Event, got {:?}", other),
839 }
840 }
841
842 // ============ Multiple Items ============
843
844 #[test]
845 fn multiple_items_preserve_source_index() {
846 let items: rhai::Array = (0..3)
847 .map(|i| {
848 let m = make_map(vec![
849 ("description", Dynamic::from(format!("Task {}", i))),
850 ]);
851 Dynamic::from_map(m)
852 })
853 .collect();
854
855 let result_dyn = wrap_result("task", items);
856 let parsed = dynamic_to_import_result(result_dyn, "test-plugin").unwrap();
857
858 assert_eq!(parsed.items.len(), 3);
859 for (idx, item) in parsed.items.iter().enumerate() {
860 assert_eq!(item.source_index, idx);
861 match &item.data {
862 ImportItemData::Task(t) => {
863 assert_eq!(t.description, format!("Task {}", idx));
864 }
865 other => panic!("Expected Task, got {:?}", other),
866 }
867 }
868 }
869
870 // ============ Warnings ============
871
872 #[test]
873 fn warnings_are_collected() {
874 let mut result = Map::new();
875 result.insert("entity_type".into(), Dynamic::from("task".to_string()));
876 result.insert("items".into(), Dynamic::from(rhai::Array::new()));
877
878 let warnings: rhai::Array = vec![
879 Dynamic::from("Skipped row 3".to_string()),
880 Dynamic::from("Unknown column: foo".to_string()),
881 ];
882 result.insert("warnings".into(), Dynamic::from(warnings));
883
884 let parsed =
885 dynamic_to_import_result(Dynamic::from_map(result), "test-plugin").unwrap();
886
887 assert_eq!(parsed.warnings.len(), 2);
888 assert_eq!(parsed.warnings[0], "Skipped row 3");
889 assert_eq!(parsed.warnings[1], "Unknown column: foo");
890 }
891
892 // ============ Error Cases ============
893
894 #[test]
895 fn missing_entity_type_is_error() {
896 let mut result = Map::new();
897 result.insert("items".into(), Dynamic::from(rhai::Array::new()));
898
899 let err = dynamic_to_import_result(Dynamic::from_map(result), "test-plugin");
900 assert!(err.is_err());
901 }
902
903 #[test]
904 fn unknown_entity_type_is_error() {
905 let result_dyn = wrap_result("contact", rhai::Array::new());
906 let err = dynamic_to_import_result(result_dyn, "test-plugin");
907 assert!(err.is_err());
908 }
909
910 #[test]
911 fn non_map_item_is_error() {
912 let items: rhai::Array = vec![Dynamic::from("not a map".to_string())];
913 let result_dyn = wrap_result("task", items);
914 let err = dynamic_to_import_result(result_dyn, "test-plugin");
915 assert!(err.is_err());
916 }
917
918 #[test]
919 fn non_map_top_level_is_error() {
920 let err = dynamic_to_import_result(Dynamic::from(42_i64), "test-plugin");
921 assert!(err.is_err());
922 }
923
924 // ============ json_to_dynamic ============
925
926 #[test]
927 fn json_to_dynamic_primitives() {
928 assert_eq!(json_to_dynamic(serde_json::Value::Null).type_name(), "()");
929 assert!(json_to_dynamic(serde_json::Value::Bool(true)).as_bool().unwrap());
930 assert_eq!(json_to_dynamic(serde_json::json!(42)).as_int().unwrap(), 42);
931 assert_eq!(json_to_dynamic(serde_json::json!(2.72)).as_float().unwrap(), 2.72);
932 assert_eq!(
933 json_to_dynamic(serde_json::json!("hello")).into_string().unwrap(),
934 "hello"
935 );
936 }
937
938 #[test]
939 fn json_to_dynamic_array() {
940 let val = serde_json::json!([1, "two", true]);
941 let dyn_val = json_to_dynamic(val);
942 let arr = dyn_val.try_cast::<rhai::Array>().unwrap();
943 assert_eq!(arr.len(), 3);
944 assert_eq!(arr[0].as_int().unwrap(), 1);
945 assert_eq!(arr[1].clone().into_string().unwrap(), "two");
946 assert!(arr[2].as_bool().unwrap());
947 }
948
949 #[test]
950 fn json_to_dynamic_object() {
951 let val = serde_json::json!({"name": "test", "count": 5});
952 let dyn_val = json_to_dynamic(val);
953 let map = dyn_val.try_cast::<Map>().unwrap();
954 assert_eq!(map.get("name").unwrap().clone().into_string().unwrap(), "test");
955 assert_eq!(map.get("count").unwrap().as_int().unwrap(), 5);
956 }
957
958 #[test]
959 fn json_to_dynamic_nested() {
960 let val = serde_json::json!({"items": [{"id": 1}, {"id": 2}]});
961 let dyn_val = json_to_dynamic(val);
962 let map = dyn_val.try_cast::<Map>().unwrap();
963 let items = map.get("items").unwrap().clone().try_cast::<rhai::Array>().unwrap();
964 assert_eq!(items.len(), 2);
965 let first = items[0].clone().try_cast::<Map>().unwrap();
966 assert_eq!(first.get("id").unwrap().as_int().unwrap(), 1);
967 }
968
969 // ============ Result Builders ============
970
971 #[test]
972 fn task_result_builder() {
973 let items: rhai::Array = vec![Dynamic::from("placeholder".to_string())];
974 let result = goingson_api::task_result(items);
975 let map = result.try_cast::<Map>().unwrap();
976
977 assert_eq!(map.get("entity_type").unwrap().clone().into_string().unwrap(), "task");
978 let items_arr = map.get("items").unwrap().clone().try_cast::<rhai::Array>().unwrap();
979 assert_eq!(items_arr.len(), 1);
980 let warnings = map.get("warnings").unwrap().clone().try_cast::<rhai::Array>().unwrap();
981 assert!(warnings.is_empty());
982 }
983
984 #[test]
985 fn project_result_builder() {
986 let result = goingson_api::project_result(rhai::Array::new());
987 let map = result.try_cast::<Map>().unwrap();
988 assert_eq!(map.get("entity_type").unwrap().clone().into_string().unwrap(), "project");
989 }
990
991 #[test]
992 fn event_result_builder() {
993 let result = goingson_api::event_result(rhai::Array::new());
994 let map = result.try_cast::<Map>().unwrap();
995 assert_eq!(map.get("entity_type").unwrap().clone().into_string().unwrap(), "event");
996 }
997
998 // ============ Context Management ============
999
1000 #[test]
1001 fn context_set_and_get() {
1002 PluginApiContext::clear();
1003
1004 let ctx = PluginApiContext {
1005 import_file_path: Some("/tmp/test.csv".to_string()),
1006 can_read_files: true,
1007 can_write_db: false,
1008 logs: vec![("info".to_string(), "hi".to_string())],
1009 progress: Some((5, 10, "halfway".to_string())),
1010 projects: vec![("p1".to_string(), "Project One".to_string())],
1011 };
1012 PluginApiContext::set(ctx);
1013
1014 let got = PluginApiContext::get();
1015 assert_eq!(got.import_file_path.as_deref(), Some("/tmp/test.csv"));
1016 assert!(got.can_read_files);
1017 assert!(!got.can_write_db);
1018 assert_eq!(got.logs.len(), 1);
1019 assert_eq!(got.progress, Some((5, 10, "halfway".to_string())));
1020 assert_eq!(got.projects.len(), 1);
1021
1022 PluginApiContext::clear();
1023 }
1024
1025 #[test]
1026 fn context_clear_resets() {
1027 PluginApiContext::set(PluginApiContext {
1028 can_read_files: true,
1029 ..Default::default()
1030 });
1031 PluginApiContext::clear();
1032
1033 let got = PluginApiContext::get();
1034 assert!(!got.can_read_files);
1035 assert!(got.import_file_path.is_none());
1036 assert!(got.logs.is_empty());
1037 assert!(got.progress.is_none());
1038 assert!(got.projects.is_empty());
1039 }
1040
1041 // ============ Round-trip: result builder -> dynamic_to_import_result ============
1042
1043 #[test]
1044 fn task_result_builder_round_trip() {
1045 let task_map = make_map(vec![
1046 ("description", Dynamic::from("Round-trip task".to_string())),
1047 ("priority", Dynamic::from("Medium".to_string())),
1048 ]);
1049
1050 let result = goingson_api::task_result(vec![Dynamic::from_map(task_map)]);
1051 let parsed = dynamic_to_import_result(result, "roundtrip").unwrap();
1052
1053 assert_eq!(parsed.entity_type, ImportEntityType::Task);
1054 match &parsed.items[0].data {
1055 ImportItemData::Task(t) => {
1056 assert_eq!(t.description, "Round-trip task");
1057 assert_eq!(t.priority.as_deref(), Some("Medium"));
1058 }
1059 other => panic!("Expected Task, got {:?}", other),
1060 }
1061 }
1062
1063 #[test]
1064 fn project_result_builder_round_trip() {
1065 let project_map = make_map(vec![
1066 ("name", Dynamic::from("Round-trip project".to_string())),
1067 ("description", Dynamic::from("Testing".to_string())),
1068 ]);
1069
1070 let result = goingson_api::project_result(vec![Dynamic::from_map(project_map)]);
1071 let parsed = dynamic_to_import_result(result, "roundtrip").unwrap();
1072
1073 assert_eq!(parsed.entity_type, ImportEntityType::Project);
1074 match &parsed.items[0].data {
1075 ImportItemData::Project(p) => {
1076 assert_eq!(p.name, "Round-trip project");
1077 assert_eq!(p.description.as_deref(), Some("Testing"));
1078 }
1079 other => panic!("Expected Project, got {:?}", other),
1080 }
1081 }
1082
1083 #[test]
1084 fn event_result_builder_round_trip() {
1085 let event_map = make_map(vec![
1086 ("title", Dynamic::from("Round-trip event".to_string())),
1087 ("start", Dynamic::from("2025-12-25T10:00:00".to_string())),
1088 ("end", Dynamic::from("2025-12-25T11:00:00".to_string())),
1089 ("location", Dynamic::from("North Pole".to_string())),
1090 ]);
1091
1092 let result = goingson_api::event_result(vec![Dynamic::from_map(event_map)]);
1093 let parsed = dynamic_to_import_result(result, "roundtrip").unwrap();
1094
1095 assert_eq!(parsed.entity_type, ImportEntityType::Event);
1096 match &parsed.items[0].data {
1097 ImportItemData::Event(e) => {
1098 assert_eq!(e.title, "Round-trip event");
1099 assert_eq!(e.start, "2025-12-25T10:00:00");
1100 assert_eq!(e.end.as_deref(), Some("2025-12-25T11:00:00"));
1101 assert_eq!(e.location.as_deref(), Some("North Pole"));
1102 }
1103 other => panic!("Expected Event, got {:?}", other),
1104 }
1105 }
1106 }
1107