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::activation(namespace = "interactive",
39version = "1.0.0",
40description = "Interactive methods demonstrating bidirectional communication", crate_path = "plexus_core")]
41impl Interactive {
42 #[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 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 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 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 yield WizardEvent::Created { name, template };
128 yield WizardEvent::Done;
129 }
130 }
131
132 #[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 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 for path in paths {
159 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 yield DeleteEvent::Cancelled;
170 }
171 Err(_) => {
172 yield DeleteEvent::Cancelled;
173 }
174 }
175 }
176 }
177
178 #[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 assert!(matches!(events.last(), Some(WizardEvent::Done)));
224
225 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 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 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}