1use std::future::Future;
44use std::pin::Pin;
45
46use serde_json::{Value, json};
47
48use plushie_core::ops::EffectRequest;
49use plushie_widget_sdk::protocol::EffectResponse;
50
51use super::EffectHandler;
52
53pub struct NativeEffectHandler;
60
61impl EffectHandler for NativeEffectHandler {
62 fn handle_sync(&self, id: &str, request: &EffectRequest) -> Option<EffectResponse> {
63 let (kind, payload) = plushie_core::ops::effect_request_to_wire(request);
64 Some(handle_effect(id.to_string(), kind, &payload))
65 }
66
67 fn handle_async(
68 &self,
69 id: String,
70 request: EffectRequest,
71 ) -> Pin<Box<dyn Future<Output = EffectResponse> + Send>> {
72 let (kind, payload) = plushie_core::ops::effect_request_to_wire(&request);
73 let kind = kind.to_string();
74 Box::pin(async move { handle_async_effect(id, &kind, &payload).await })
75 }
76
77 fn is_async(&self, request: &EffectRequest) -> bool {
78 matches!(
79 request,
80 EffectRequest::FileOpen(_)
81 | EffectRequest::FileOpenMultiple(_)
82 | EffectRequest::FileSave(_)
83 | EffectRequest::DirectorySelect(_)
84 | EffectRequest::DirectorySelectMultiple(_)
85 )
86 }
87}
88
89fn path_to_json_string(path: &std::path::Path) -> String {
99 match path.to_str() {
100 Some(s) => s.to_string(),
101 None => {
102 log::warn!(
103 "file path contains non-UTF-8 bytes, using lossy conversion: {}",
104 path.display()
105 );
106 path.to_string_lossy().into_owned()
107 }
108 }
109}
110
111struct DialogParams<'a> {
115 title: &'a str,
116 filters: Vec<(&'a str, Vec<&'a str>)>,
117 directory: Option<&'a str>,
118 default_name: Option<&'a str>,
119}
120
121fn parse_dialog_params<'a>(payload: &'a Value, default_title: &'a str) -> DialogParams<'a> {
123 let title = payload
124 .get("title")
125 .and_then(|v| v.as_str())
126 .unwrap_or(default_title);
127
128 let mut filters = Vec::new();
129 if let Some(arr) = payload.get("filters").and_then(|v| v.as_array()) {
130 for filter in arr {
131 if let Some(pair) = filter.as_array()
132 && pair.len() >= 2
133 && let (Some(name), Some(ext)) = (pair[0].as_str(), pair[1].as_str())
134 {
135 let extensions: Vec<&str> = ext
136 .split(';')
137 .map(|e| e.trim().trim_start_matches("*."))
138 .collect();
139 filters.push((name, extensions));
140 }
141 }
142 }
143
144 let directory = payload.get("directory").and_then(|v| v.as_str());
145 let default_name = payload.get("default_name").and_then(|v| v.as_str());
146
147 DialogParams {
148 title,
149 filters,
150 directory,
151 default_name,
152 }
153}
154
155macro_rules! apply_dialog_params {
158 ($dialog_type:ty, $params:expr) => {{
159 let params = &$params;
160 let mut d = <$dialog_type>::new().set_title(params.title);
161 for (name, exts) in ¶ms.filters {
162 d = d.add_filter(*name, exts);
163 }
164 if let Some(dir) = params.directory {
165 d = d.set_directory(dir);
166 }
167 if let Some(name) = params.default_name {
168 d = d.set_file_name(name);
169 }
170 d
171 }};
172}
173
174pub fn is_async_effect(kind: &str) -> bool {
178 matches!(
179 kind,
180 "file_open"
181 | "file_open_multiple"
182 | "file_save"
183 | "directory_select"
184 | "directory_select_multiple"
185 )
186}
187
188pub fn handle_effect(id: String, kind: &str, payload: &Value) -> EffectResponse {
197 match kind {
198 "file_open" => handle_file_open(id, payload),
199 "file_open_multiple" => handle_file_open_multiple(id, payload),
200 "file_save" => handle_file_save(id, payload),
201 "directory_select" => handle_directory_select(id, payload),
202 "directory_select_multiple" => handle_directory_select_multiple(id, payload),
203 "clipboard_read" => handle_clipboard_read(id),
204 "clipboard_write" => handle_clipboard_write(id, payload),
205 "clipboard_read_html" => handle_clipboard_read_html(id),
206 "clipboard_write_html" => handle_clipboard_write_html(id, payload),
207 "clipboard_clear" => handle_clipboard_clear(id),
208 "clipboard_read_primary" => handle_clipboard_read_primary(id),
209 "clipboard_write_primary" => handle_clipboard_write_primary(id, payload),
210 "notification" => handle_notification(id, payload),
211 _ => EffectResponse::unsupported(id),
212 }
213}
214
215pub async fn handle_async_effect(id: String, kind: &str, payload: &Value) -> EffectResponse {
226 match kind {
227 "file_open" => {
228 let p = parse_dialog_params(payload, "Open File");
229 let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
230 match dialog.pick_file().await {
231 Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
232 None => EffectResponse::cancelled(id),
233 }
234 }
235 "file_open_multiple" => {
236 let p = parse_dialog_params(payload, "Open Files");
237 let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
238 match dialog.pick_files().await {
239 Some(handles) => {
240 let paths: Vec<String> = handles
241 .iter()
242 .map(|h| path_to_json_string(h.path()))
243 .collect();
244 EffectResponse::ok(id, json!({"paths": paths}))
245 }
246 None => EffectResponse::cancelled(id),
247 }
248 }
249 "file_save" => {
250 let p = parse_dialog_params(payload, "Save File");
251 let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
252 match dialog.save_file().await {
253 Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
254 None => EffectResponse::cancelled(id),
255 }
256 }
257 "directory_select" => {
258 let p = parse_dialog_params(payload, "Select Directory");
259 let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
260 match dialog.pick_folder().await {
261 Some(h) => EffectResponse::ok(id, json!({"path": path_to_json_string(h.path())})),
262 None => EffectResponse::cancelled(id),
263 }
264 }
265 "directory_select_multiple" => {
266 let p = parse_dialog_params(payload, "Select Directories");
267 let dialog = apply_dialog_params!(rfd::AsyncFileDialog, p);
268 match dialog.pick_folders().await {
269 Some(handles) => {
270 let paths: Vec<String> = handles
271 .iter()
272 .map(|h| path_to_json_string(h.path()))
273 .collect();
274 EffectResponse::ok(id, json!({"paths": paths}))
275 }
276 None => EffectResponse::cancelled(id),
277 }
278 }
279 _ => EffectResponse::unsupported(id),
280 }
281}
282
283fn handle_file_open(id: String, payload: &Value) -> EffectResponse {
290 let p = parse_dialog_params(payload, "Open File");
291 let dialog = apply_dialog_params!(rfd::FileDialog, p);
292 match dialog.pick_file() {
293 Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
294 None => EffectResponse::cancelled(id),
295 }
296}
297
298fn handle_file_open_multiple(id: String, payload: &Value) -> EffectResponse {
299 let p = parse_dialog_params(payload, "Open Files");
300 let dialog = apply_dialog_params!(rfd::FileDialog, p);
301 match dialog.pick_files() {
302 Some(paths) => {
303 let paths: Vec<String> = paths.iter().map(|p| path_to_json_string(p)).collect();
304 EffectResponse::ok(id, json!({"paths": paths}))
305 }
306 None => EffectResponse::cancelled(id),
307 }
308}
309
310fn handle_file_save(id: String, payload: &Value) -> EffectResponse {
311 let p = parse_dialog_params(payload, "Save File");
312 let dialog = apply_dialog_params!(rfd::FileDialog, p);
313 match dialog.save_file() {
314 Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
315 None => EffectResponse::cancelled(id),
316 }
317}
318
319fn handle_directory_select(id: String, payload: &Value) -> EffectResponse {
320 let p = parse_dialog_params(payload, "Select Directory");
321 let dialog = apply_dialog_params!(rfd::FileDialog, p);
322 match dialog.pick_folder() {
323 Some(path) => EffectResponse::ok(id, json!({"path": path_to_json_string(&path)})),
324 None => EffectResponse::cancelled(id),
325 }
326}
327
328fn handle_directory_select_multiple(id: String, payload: &Value) -> EffectResponse {
329 let p = parse_dialog_params(payload, "Select Directories");
330 let dialog = apply_dialog_params!(rfd::FileDialog, p);
331 match dialog.pick_folders() {
332 Some(paths) => {
333 let paths: Vec<String> = paths.iter().map(|p| path_to_json_string(p)).collect();
334 EffectResponse::ok(id, json!({"paths": paths}))
335 }
336 None => EffectResponse::cancelled(id),
337 }
338}
339
340fn with_clipboard(
347 id: &str,
348 f: impl FnOnce(&mut arboard::Clipboard, &str) -> EffectResponse,
349) -> EffectResponse {
350 use std::sync::Mutex;
351
352 static CLIPBOARD: Mutex<Option<arboard::Clipboard>> = Mutex::new(None);
353
354 let mut guard = CLIPBOARD.lock().unwrap_or_else(|poisoned| {
355 log::warn!("clipboard mutex was poisoned, recovering");
356 poisoned.into_inner()
357 });
358
359 let clipboard = match guard.as_mut() {
360 Some(c) => c,
361 None => match arboard::Clipboard::new() {
362 Ok(c) => {
363 *guard = Some(c);
364 guard.as_mut().unwrap()
365 }
366 Err(e) => {
367 return EffectResponse::error(
368 id.to_string(),
369 format!("clipboard init failed: {e}"),
370 );
371 }
372 },
373 };
374
375 f(clipboard, id)
376}
377
378fn handle_clipboard_read(id: String) -> EffectResponse {
379 with_clipboard(&id, |clipboard, id| match clipboard.get_text() {
384 Ok(text) => EffectResponse::ok(id.to_string(), json!({"text": text})),
385 Err(arboard::Error::ContentNotAvailable) => {
386 EffectResponse::ok(id.to_string(), json!({"text": ""}))
387 }
388 Err(e) => EffectResponse::error(id.to_string(), format!("clipboard read failed: {e}")),
389 })
390}
391
392fn handle_clipboard_write(id: String, payload: &Value) -> EffectResponse {
393 let Some(text) = payload.get("text").and_then(|v| v.as_str()) else {
394 return EffectResponse::error(id, "missing required field: text".to_string());
395 };
396 let text = text.to_string();
397
398 with_clipboard(&id, |clipboard, id| match clipboard.set_text(text) {
399 Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
400 Err(e) => EffectResponse::error(id.to_string(), format!("clipboard write failed: {e}")),
401 })
402}
403
404fn handle_clipboard_read_html(id: String) -> EffectResponse {
405 with_clipboard(&id, |clipboard, id| match clipboard.get().html() {
406 Ok(html) => EffectResponse::ok(id.to_string(), json!({"html": html})),
407 Err(e) => EffectResponse::error(id.to_string(), format!("clipboard read html failed: {e}")),
408 })
409}
410
411fn handle_clipboard_write_html(id: String, payload: &Value) -> EffectResponse {
412 let Some(html) = payload.get("html").and_then(|v| v.as_str()) else {
413 return EffectResponse::error(id, "missing required field: html".to_string());
414 };
415 let html = html.to_string();
416
417 let alt_text = payload
418 .get("alt_text")
419 .and_then(|v| v.as_str())
420 .map(|s| s.to_string());
421
422 with_clipboard(&id, |clipboard, id| {
423 match clipboard.set_html(&html, alt_text.as_ref()) {
424 Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
425 Err(e) => {
426 EffectResponse::error(id.to_string(), format!("clipboard write html failed: {e}"))
427 }
428 }
429 })
430}
431
432fn handle_clipboard_clear(id: String) -> EffectResponse {
433 with_clipboard(&id, |clipboard, id| match clipboard.clear() {
434 Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
435 Err(e) => EffectResponse::error(id.to_string(), format!("clipboard clear failed: {e}")),
436 })
437}
438
439#[cfg(target_os = "linux")]
443fn handle_clipboard_read_primary(id: String) -> EffectResponse {
444 use arboard::{GetExtLinux, LinuxClipboardKind};
445
446 with_clipboard(&id, |clipboard, id| {
447 match clipboard
448 .get()
449 .clipboard(LinuxClipboardKind::Primary)
450 .text()
451 {
452 Ok(text) => EffectResponse::ok(id.to_string(), json!({"text": text})),
453 Err(e) => EffectResponse::error(
454 id.to_string(),
455 format!("primary clipboard read failed: {e}"),
456 ),
457 }
458 })
459}
460
461#[cfg(target_os = "linux")]
462fn handle_clipboard_write_primary(id: String, payload: &Value) -> EffectResponse {
463 use arboard::{LinuxClipboardKind, SetExtLinux};
464 let Some(text) = payload.get("text").and_then(|v| v.as_str()) else {
465 return EffectResponse::error(id, "missing required field: text".to_string());
466 };
467 let text = text.to_string();
468
469 with_clipboard(&id, |clipboard, id| {
470 match clipboard
471 .set()
472 .clipboard(LinuxClipboardKind::Primary)
473 .text(text)
474 {
475 Ok(()) => EffectResponse::ok(id.to_string(), json!(null)),
476 Err(e) => EffectResponse::error(
477 id.to_string(),
478 format!("primary clipboard write failed: {e}"),
479 ),
480 }
481 })
482}
483
484#[cfg(not(target_os = "linux"))]
485fn handle_clipboard_read_primary(id: String) -> EffectResponse {
486 EffectResponse::unsupported(id)
487}
488
489#[cfg(not(target_os = "linux"))]
490fn handle_clipboard_write_primary(id: String, _payload: &Value) -> EffectResponse {
491 EffectResponse::unsupported(id)
492}
493
494fn handle_notification(id: String, payload: &Value) -> EffectResponse {
509 let title = payload
510 .get("title")
511 .and_then(|v| v.as_str())
512 .unwrap_or("Plushie");
513
514 let body = payload.get("body").and_then(|v| v.as_str()).unwrap_or("");
515
516 let mut notification = notify_rust::Notification::new();
517 notification.summary(title).body(body);
518
519 if let Some(icon) = payload.get("icon").and_then(|v| v.as_str()) {
520 notification.icon(icon);
521 }
522
523 if let Some(timeout_ms) = payload.get("timeout").and_then(|v| v.as_u64()) {
524 let clamped = timeout_ms.min(u32::MAX as u64) as u32;
525 notification.timeout(notify_rust::Timeout::Milliseconds(clamped));
526 }
527
528 #[cfg(target_os = "linux")]
529 if let Some(urgency) = payload.get("urgency").and_then(|v| v.as_str()) {
530 let u = match urgency {
531 "low" => notify_rust::Urgency::Low,
532 "critical" => notify_rust::Urgency::Critical,
533 _ => notify_rust::Urgency::Normal,
534 };
535 notification.urgency(u);
536 }
537
538 if let Some(sound) = payload.get("sound").and_then(|v| v.as_str()) {
539 notification.sound_name(sound);
540 }
541
542 match notification.show() {
543 Ok(_) => EffectResponse::ok(id, json!(null)),
544 Err(e) => EffectResponse::error(id, format!("notification failed: {e}")),
545 }
546}
547
548#[cfg(test)]
551mod tests {
552 use super::*;
553 use serde_json::json;
554
555 #[test]
556 fn unknown_effect_returns_unsupported() {
557 let resp = handle_effect("eff-1".to_string(), "teleport_sandwich", &json!({}));
558 assert_eq!(resp.status, "unsupported");
559 assert_eq!(resp.id, "eff-1");
560 }
561
562 #[test]
575 fn dispatch_routes_all_known_kinds_without_panic() {
576 let kinds_with_payloads: Vec<(&str, Value)> = vec![
577 #[cfg(target_os = "linux")]
578 ("file_open", json!({"title": "Pick a file"})),
579 #[cfg(target_os = "linux")]
580 ("file_open_multiple", json!({"title": "Pick files"})),
581 #[cfg(target_os = "linux")]
582 (
583 "file_save",
584 json!({"title": "Save", "default_name": "out.txt"}),
585 ),
586 #[cfg(target_os = "linux")]
587 ("directory_select", json!({"title": "Choose dir"})),
588 #[cfg(target_os = "linux")]
589 ("directory_select_multiple", json!({"title": "Choose dirs"})),
590 ("clipboard_read", json!({})),
591 ("clipboard_write", json!({"text": "hello"})),
592 ("clipboard_read_html", json!({})),
593 (
594 "clipboard_write_html",
595 json!({"html": "<b>hi</b>", "alt_text": "hi"}),
596 ),
597 ("clipboard_clear", json!({})),
598 ("clipboard_read_primary", json!({})),
599 ("clipboard_write_primary", json!({"text": "primary"})),
600 (
601 "notification",
602 json!({"title": "Test", "body": "body", "icon": "dialog-information", "timeout": 3000, "urgency": "low", "sound": "message-new-instant"}),
603 ),
604 ];
605
606 for (kind, payload) in &kinds_with_payloads {
607 let id = format!("test-{kind}");
608 let resp = handle_effect(id.clone(), kind, payload);
609
610 assert_eq!(resp.id, id, "id mismatch for kind {kind}");
611 assert_eq!(resp.message_type, "effect_response");
612 #[cfg(target_os = "linux")]
613 assert!(
614 resp.status == "ok" || resp.status == "error" || resp.status == "cancelled",
615 "unexpected status '{}' for kind {kind}",
616 resp.status
617 );
618
619 #[cfg(not(target_os = "linux"))]
620 assert!(
621 resp.status == "ok"
622 || resp.status == "error"
623 || resp.status == "cancelled"
624 || (resp.status == "unsupported"
625 && matches!(*kind, "clipboard_read_primary" | "clipboard_write_primary")),
626 "unexpected status '{}' for kind {kind}",
627 resp.status
628 );
629 }
630 }
631
632 #[test]
646 fn trait_impl_matches_free_function_for_all_sync_kinds() {
647 use plushie_core::ops::{EffectRequest, NotificationOpts};
648
649 let sync_requests: Vec<(&str, EffectRequest)> = vec![
652 ("clipboard_read", EffectRequest::ClipboardRead),
653 (
654 "clipboard_write",
655 EffectRequest::ClipboardWrite("hello".to_string()),
656 ),
657 ("clipboard_read_html", EffectRequest::ClipboardReadHtml),
658 (
659 "clipboard_write_html",
660 EffectRequest::ClipboardWriteHtml {
661 html: "<b>hi</b>".to_string(),
662 alt_text: Some("hi".to_string()),
663 },
664 ),
665 ("clipboard_clear", EffectRequest::ClipboardClear),
666 (
667 "clipboard_read_primary",
668 EffectRequest::ClipboardReadPrimary,
669 ),
670 (
671 "clipboard_write_primary",
672 EffectRequest::ClipboardWritePrimary("primary".to_string()),
673 ),
674 (
675 "notification",
676 EffectRequest::Notification {
677 title: "Test".to_string(),
678 body: "body".to_string(),
679 opts: NotificationOpts::new()
680 .icon("dialog-information")
681 .timeout(std::time::Duration::from_millis(3000))
682 .sound("message-new-instant"),
683 },
684 ),
685 ];
686
687 let handler = NativeEffectHandler;
688 for (kind, request) in &sync_requests {
689 let id = format!("converge-{kind}");
690
691 let trait_resp = handler
693 .handle_sync(&id, request)
694 .expect("sync request must produce a response");
695
696 let (wire_kind, payload) = plushie_core::ops::effect_request_to_wire(request);
700 assert_eq!(
701 wire_kind, *kind,
702 "wire kind mismatch for {kind} (typed -> wire)"
703 );
704 let fn_resp = handle_effect(id.clone(), wire_kind, &payload);
705
706 assert_eq!(trait_resp.id, fn_resp.id, "id mismatch for {kind}");
708 assert_eq!(
709 trait_resp.message_type, fn_resp.message_type,
710 "message_type mismatch for {kind}"
711 );
712 assert_eq!(
713 trait_resp.status, fn_resp.status,
714 "status mismatch for {kind}"
715 );
716
717 assert_eq!(
724 trait_resp.result.is_some(),
725 fn_resp.result.is_some(),
726 "result presence mismatch for {kind}"
727 );
728 assert_eq!(
729 trait_resp.error.is_some(),
730 fn_resp.error.is_some(),
731 "error presence mismatch for {kind}"
732 );
733 }
734 }
735
736 #[test]
746 fn async_routing_agrees_between_trait_impl_and_free_function() {
747 use plushie_core::ops::EffectRequest;
748
749 let async_requests: Vec<(&str, EffectRequest)> = vec![
750 ("file_open", EffectRequest::FileOpen(Default::default())),
751 (
752 "file_open_multiple",
753 EffectRequest::FileOpenMultiple(Default::default()),
754 ),
755 ("file_save", EffectRequest::FileSave(Default::default())),
756 (
757 "directory_select",
758 EffectRequest::DirectorySelect(Default::default()),
759 ),
760 (
761 "directory_select_multiple",
762 EffectRequest::DirectorySelectMultiple(Default::default()),
763 ),
764 ];
765
766 let handler = NativeEffectHandler;
767 for (wire_kind, request) in &async_requests {
768 assert!(
769 handler.is_async(request),
770 "trait impl should route {wire_kind} async"
771 );
772 assert!(
773 is_async_effect(wire_kind),
774 "free function should route {wire_kind} async"
775 );
776 }
777
778 let sync_examples: Vec<(&str, EffectRequest)> = vec![
780 ("clipboard_read", EffectRequest::ClipboardRead),
781 ("clipboard_clear", EffectRequest::ClipboardClear),
782 (
783 "notification",
784 EffectRequest::Notification {
785 title: String::new(),
786 body: String::new(),
787 opts: plushie_core::ops::NotificationOpts::default(),
788 },
789 ),
790 ];
791 for (wire_kind, request) in &sync_examples {
792 assert!(
793 !handler.is_async(request),
794 "trait impl should route {wire_kind} sync"
795 );
796 assert!(
797 !is_async_effect(wire_kind),
798 "free function should route {wire_kind} sync"
799 );
800 }
801 }
802
803 #[test]
809 fn handlers_tolerate_empty_payloads() {
810 let kinds: &[&str] = &[
811 #[cfg(target_os = "linux")]
812 "file_open",
813 #[cfg(target_os = "linux")]
814 "file_open_multiple",
815 #[cfg(target_os = "linux")]
816 "file_save",
817 #[cfg(target_os = "linux")]
818 "directory_select",
819 #[cfg(target_os = "linux")]
820 "directory_select_multiple",
821 "clipboard_read",
822 "clipboard_write",
823 "clipboard_read_html",
824 "clipboard_write_html",
825 "clipboard_clear",
826 "clipboard_read_primary",
827 "clipboard_write_primary",
828 "notification",
829 ];
830
831 for kind in kinds {
832 let resp = handle_effect(format!("empty-{kind}"), kind, &json!({}));
833 assert_eq!(resp.message_type, "effect_response");
834 }
835 }
836
837 #[test]
838 fn unknown_kinds_preserve_id() {
839 for i in 0..5 {
840 let id = format!("unk-{i}");
841 let resp = handle_effect(id.clone(), &format!("bogus_{i}"), &json!(null));
842 assert_eq!(resp.id, id);
843 assert_eq!(resp.status, "unsupported");
844 }
845 }
846
847 #[cfg(not(target_os = "linux"))]
848 #[test]
849 fn primary_clipboard_effects_are_unsupported() {
850 let read = handle_effect(
851 "read-primary".to_string(),
852 "clipboard_read_primary",
853 &json!({}),
854 );
855 assert_eq!(read.status, "unsupported");
856 assert_eq!(read.id, "read-primary");
857
858 let write = handle_effect(
859 "write-primary".to_string(),
860 "clipboard_write_primary",
861 &json!({"text": "primary"}),
862 );
863 assert_eq!(write.status, "unsupported");
864 assert_eq!(write.id, "write-primary");
865 }
866
867 #[test]
870 fn async_effects_recognized() {
871 assert!(is_async_effect("file_open"));
872 assert!(is_async_effect("file_open_multiple"));
873 assert!(is_async_effect("file_save"));
874 assert!(is_async_effect("directory_select"));
875 assert!(is_async_effect("directory_select_multiple"));
876 }
877
878 #[test]
879 fn sync_effects_not_async() {
880 assert!(!is_async_effect("clipboard_read"));
881 assert!(!is_async_effect("clipboard_write"));
882 assert!(!is_async_effect("notification"));
883 }
884
885 #[test]
886 fn unknown_effect_not_async() {
887 assert!(!is_async_effect("teleport_sandwich"));
888 assert!(!is_async_effect(""));
889 assert!(!is_async_effect("FILE_OPEN")); }
891
892 #[test]
895 fn parse_params_defaults() {
896 let payload = json!({});
897 let p = parse_dialog_params(&payload, "Default Title");
898 assert_eq!(p.title, "Default Title");
899 assert!(p.filters.is_empty());
900 assert!(p.directory.is_none());
901 assert!(p.default_name.is_none());
902 }
903
904 #[test]
905 fn parse_params_with_all_fields() {
906 let payload = json!({
907 "title": "Custom Title",
908 "filters": [["Images", "*.png;*.jpg"], ["All", "*.*"]],
909 "directory": "/home/user",
910 "default_name": "output.txt"
911 });
912 let p = parse_dialog_params(&payload, "Ignored");
913 assert_eq!(p.title, "Custom Title");
914 assert_eq!(p.filters.len(), 2);
915 assert_eq!(p.filters[0].0, "Images");
916 assert_eq!(p.filters[0].1, vec!["png", "jpg"]);
917 assert_eq!(p.filters[1].0, "All");
918 assert_eq!(p.directory, Some("/home/user"));
919 assert_eq!(p.default_name, Some("output.txt"));
920 }
921
922 #[test]
923 fn parse_params_malformed_filters_ignored() {
924 let payload = json!({
925 "filters": [
926 "not an array",
927 [],
928 ["only one element"],
929 ["Name", "*.txt"]
930 ]
931 });
932 let p = parse_dialog_params(&payload, "T");
933 assert_eq!(p.filters.len(), 1);
935 assert_eq!(p.filters[0].0, "Name");
936 }
937
938 #[test]
941 fn path_normal() {
942 use std::path::Path;
943 assert_eq!(
944 path_to_json_string(Path::new("/home/user/file.txt")),
945 "/home/user/file.txt"
946 );
947 }
948
949 #[test]
950 fn path_empty() {
951 use std::path::Path;
952 assert_eq!(path_to_json_string(Path::new("")), "");
953 }
954
955 #[test]
956 fn path_with_spaces() {
957 use std::path::Path;
958 assert_eq!(
959 path_to_json_string(Path::new("/home/user/my documents/file.txt")),
960 "/home/user/my documents/file.txt"
961 );
962 }
963
964 #[test]
965 fn path_with_special_chars() {
966 use std::path::Path;
967 assert_eq!(
968 path_to_json_string(Path::new("/tmp/test-file_v2 (1).tar.gz")),
969 "/tmp/test-file_v2 (1).tar.gz"
970 );
971 }
972
973 #[test]
974 fn empty_clipboard_returns_ok_with_empty_text() {
975 let resp = handle_clipboard_read("read-empty".to_string());
981 assert_eq!(resp.message_type, "effect_response");
982 assert_eq!(resp.id, "read-empty");
983 assert!(resp.status == "ok" || resp.status == "error");
986 }
987}