plexus_substrate/activations/interactive/
activation.rs1use 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#[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#[plexus_macros::hub_methods(
39 namespace = "interactive",
40 version = "1.0.0",
41 description = "Interactive methods demonstrating bidirectional communication"
42)]
43impl Interactive {
44 #[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 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 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 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 yield WizardEvent::Created { name, template };
130 yield WizardEvent::Done;
131 }
132 }
133
134 #[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 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 for path in paths {
161 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 yield DeleteEvent::Cancelled;
172 }
173 Err(_) => {
174 yield DeleteEvent::Cancelled;
175 }
176 }
177 }
178 }
179
180 #[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 assert!(matches!(events.last(), Some(WizardEvent::Done)));
226
227 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 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 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}