Skip to main content

plexus_substrate/activations/interactive/
activation.rs

1//! Interactive activation - demonstrates bidirectional communication patterns
2//!
3//! This activation showcases:
4//! - User confirmations via `ctx.confirm()`
5//! - Text prompts via `ctx.prompt()`
6//! - Selection menus via `ctx.select()`
7//! - Graceful handling of non-bidirectional transports
8//!
9//! The methods use `StandardBidirChannel` for common UI patterns.
10//! For custom request/response types, see the ImageProcessor example.
11
12use super::types::{ConfirmEvent, DeleteEvent, WizardEvent};
13use async_stream::stream;
14use futures::Stream;
15use plexus_core::plexus::bidirectional::{
16    bidir_error_message, BidirError, SelectOption, StandardBidirChannel,
17};
18use std::sync::Arc;
19
20/// Interactive activation demonstrating bidirectional UI patterns
21#[derive(Clone)]
22pub struct Interactive;
23
24impl Interactive {
25    pub fn new() -> Self {
26        Interactive
27    }
28}
29
30impl Default for Interactive {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36/// Hub-macro generates the Activation trait and RPC implementations.
37/// The `bidirectional` attribute on methods enables server→client requests.
38#[plexus_macros::activation(namespace = "interactive",
39version = "1.0.0",
40description = "Interactive methods demonstrating bidirectional communication", crate_path = "plexus_core")]
41impl Interactive {
42    /// Multi-step setup wizard demonstrating all bidirectional patterns
43    ///
44    /// This method demonstrates:
45    /// - Text prompts (ctx.prompt)
46    /// - Selection menus (ctx.select)
47    /// - Confirmations (ctx.confirm)
48    /// - Graceful error handling
49    #[plexus_macros::method(bidirectional, streaming)]
50    async fn wizard(
51        &self,
52        ctx: &Arc<StandardBidirChannel>,
53    ) -> impl Stream<Item = WizardEvent> + Send + 'static {
54        let ctx = ctx.clone();
55        stream! {
56            yield WizardEvent::Started;
57
58            // Step 1: Get project name
59            let name = match ctx.prompt("Enter project name:").await {
60                Ok(n) if n.is_empty() => {
61                    yield WizardEvent::Error { message: "Name cannot be empty".into() };
62                    return;
63                }
64                Ok(n) => n,
65                Err(BidirError::NotSupported) => {
66                    yield WizardEvent::Error {
67                        message: "Interactive mode required. Use a bidirectional transport.".into()
68                    };
69                    return;
70                }
71                Err(BidirError::Cancelled) => {
72                    yield WizardEvent::Cancelled;
73                    return;
74                }
75                Err(e) => {
76                    yield WizardEvent::Error { message: bidir_error_message(&e) };
77                    return;
78                }
79            };
80            yield WizardEvent::NameCollected { name: name.clone() };
81
82            // Step 2: Select template
83            let templates = vec![
84                SelectOption::new("minimal", "Minimal").with_description("Bare-bones starter"),
85                SelectOption::new("full", "Full Featured").with_description("All features included"),
86                SelectOption::new("api", "API Only").with_description("Backend API template"),
87            ];
88
89            let template = match ctx.select("Choose template:", templates).await {
90                Ok(mut selected) if !selected.is_empty() => selected.remove(0),
91                Ok(_) => {
92                    yield WizardEvent::Error { message: "No template selected".into() };
93                    return;
94                }
95                Err(BidirError::Cancelled) => {
96                    yield WizardEvent::Cancelled;
97                    return;
98                }
99                Err(e) => {
100                    yield WizardEvent::Error { message: bidir_error_message(&e) };
101                    return;
102                }
103            };
104            yield WizardEvent::TemplateSelected { template: template.clone() };
105
106            // Step 3: Confirm creation
107            let confirmed = match ctx.confirm(&format!(
108                "Create project '{}' with '{}' template?"
109            , name, template)).await {
110                Ok(c) => c,
111                Err(BidirError::Cancelled) => {
112                    yield WizardEvent::Cancelled;
113                    return;
114                }
115                Err(e) => {
116                    yield WizardEvent::Error { message: bidir_error_message(&e) };
117                    return;
118                }
119            };
120
121            if !confirmed {
122                yield WizardEvent::Cancelled;
123                return;
124            }
125
126            // Success!
127            yield WizardEvent::Created { name, template };
128            yield WizardEvent::Done;
129        }
130    }
131
132    /// Delete files with confirmation
133    ///
134    /// Demonstrates confirmation before destructive operations.
135    #[plexus_macros::method(bidirectional, streaming)]
136    async fn delete(
137        &self,
138        ctx: &Arc<StandardBidirChannel>,
139        paths: Vec<String>,
140    ) -> impl Stream<Item = DeleteEvent> + Send + 'static {
141        let ctx = ctx.clone();
142        stream! {
143            if paths.is_empty() {
144                yield DeleteEvent::Done;
145                return;
146            }
147
148            // Confirm deletion
149            let message = if paths.len() == 1 {
150                format!("Delete '{}'?", paths[0])
151            } else {
152                format!("Delete {} files?", paths.len())
153            };
154
155            match ctx.confirm(&message).await {
156                Ok(true) => {
157                    // User confirmed - proceed with deletion
158                    for path in paths {
159                        // In real implementation, would actually delete files
160                        yield DeleteEvent::Deleted { path };
161                    }
162                    yield DeleteEvent::Done;
163                }
164                Ok(false) | Err(BidirError::Cancelled) => {
165                    yield DeleteEvent::Cancelled;
166                }
167                Err(BidirError::NotSupported) => {
168                    // Non-interactive mode - skip deletion for safety
169                    yield DeleteEvent::Cancelled;
170                }
171                Err(_) => {
172                    yield DeleteEvent::Cancelled;
173                }
174            }
175        }
176    }
177
178    /// Simple confirmation method for testing
179    ///
180    /// Just asks a yes/no question and returns the result.
181    #[plexus_macros::method(bidirectional)]
182    async fn confirm(
183        &self,
184        ctx: &Arc<StandardBidirChannel>,
185        message: String,
186    ) -> impl Stream<Item = ConfirmEvent> + Send + 'static {
187        let ctx = ctx.clone();
188        stream! {
189            match ctx.confirm(&message).await {
190                Ok(true) => yield ConfirmEvent::Confirmed,
191                Ok(false) => yield ConfirmEvent::Declined,
192                Err(e) => yield ConfirmEvent::Error { message: bidir_error_message(&e) },
193            }
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use plexus_core::plexus::bidirectional::{
202        auto_respond_channel, StandardRequest, StandardResponse,
203    };
204    use futures::StreamExt;
205
206    #[tokio::test]
207    async fn test_wizard_with_auto_responses() {
208        let ctx = auto_respond_channel(|req: &StandardRequest| match req {
209            StandardRequest::Prompt { .. } => StandardResponse::Text {
210                value: serde_json::Value::String("my-project".into()),
211            },
212            StandardRequest::Select { options, .. } => StandardResponse::Selected {
213                values: vec![options[0].value.clone()],
214            },
215            StandardRequest::Confirm { .. } => StandardResponse::Confirmed { value: true },
216            StandardRequest::Custom { data } => StandardResponse::Custom { data: data.clone() },
217        });
218
219        let interactive = Interactive::new();
220        let events: Vec<WizardEvent> = interactive.wizard(&ctx).await.collect().await;
221
222        // Should complete successfully
223        assert!(matches!(events.last(), Some(WizardEvent::Done)));
224
225        // Check we got expected events
226        assert!(events.iter().any(|e| matches!(e, WizardEvent::Started)));
227        assert!(events.iter().any(|e| matches!(e, WizardEvent::NameCollected { name } if name == "my-project")));
228        assert!(events.iter().any(|e| matches!(e, WizardEvent::TemplateSelected { .. })));
229        assert!(events.iter().any(|e| matches!(e, WizardEvent::Created { .. })));
230    }
231
232    #[tokio::test]
233    async fn test_wizard_cancelled() {
234        let ctx = auto_respond_channel(|req: &StandardRequest| match req {
235            StandardRequest::Prompt { .. } => StandardResponse::Cancelled,
236            StandardRequest::Select { .. } => StandardResponse::Cancelled,
237            StandardRequest::Confirm { .. } => StandardResponse::Cancelled,
238            StandardRequest::Custom { .. } => StandardResponse::Cancelled,
239        });
240
241        let interactive = Interactive::new();
242        let events: Vec<WizardEvent> = interactive.wizard(&ctx).await.collect().await;
243
244        assert!(matches!(events.last(), Some(WizardEvent::Cancelled)));
245    }
246
247    #[tokio::test]
248    async fn test_delete_confirmed() {
249        let ctx = auto_respond_channel(|_: &StandardRequest| StandardResponse::Confirmed {
250            value: true,
251        });
252
253        let interactive = Interactive::new();
254        let paths = vec!["file1.txt".into(), "file2.txt".into()];
255        let events: Vec<DeleteEvent> = interactive.delete(&ctx, paths).await.collect().await;
256
257        // Should have deleted both files
258        assert!(events.iter().any(|e| matches!(e, DeleteEvent::Deleted { path } if path == "file1.txt")));
259        assert!(events.iter().any(|e| matches!(e, DeleteEvent::Deleted { path } if path == "file2.txt")));
260        assert!(matches!(events.last(), Some(DeleteEvent::Done)));
261    }
262
263    #[tokio::test]
264    async fn test_delete_declined() {
265        let ctx = auto_respond_channel(|_: &StandardRequest| StandardResponse::Confirmed {
266            value: false,
267        });
268
269        let interactive = Interactive::new();
270        let paths = vec!["file.txt".into()];
271        let events: Vec<DeleteEvent> = interactive.delete(&ctx, paths).await.collect().await;
272
273        // Should be cancelled without deleting
274        assert!(matches!(events.last(), Some(DeleteEvent::Cancelled)));
275        assert!(!events.iter().any(|e| matches!(e, DeleteEvent::Deleted { .. })));
276    }
277
278    #[tokio::test]
279    async fn test_confirm_yes() {
280        let ctx = auto_respond_channel(|_: &StandardRequest| StandardResponse::Confirmed {
281            value: true,
282        });
283
284        let interactive = Interactive::new();
285        let events: Vec<ConfirmEvent> = interactive
286            .confirm(&ctx, "Proceed?".into())
287            .await
288            .collect()
289            .await;
290
291        assert!(matches!(events.first(), Some(ConfirmEvent::Confirmed)));
292    }
293
294    #[tokio::test]
295    async fn test_confirm_no() {
296        let ctx = auto_respond_channel(|_: &StandardRequest| StandardResponse::Confirmed {
297            value: false,
298        });
299
300        let interactive = Interactive::new();
301        let events: Vec<ConfirmEvent> = interactive
302            .confirm(&ctx, "Proceed?".into())
303            .await
304            .collect()
305            .await;
306
307        assert!(matches!(events.first(), Some(ConfirmEvent::Declined)));
308    }
309}