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