//! Adds support for asking for input in the middle of a task. //! See the demo for more. // TODO: more here use thiserror::Error; use tokio::sync::{RwLock, RwLockReadGuard, oneshot}; use wallsocket_core::schema::{CompletedSchema, FieldDesc, Schema}; /// Tracks input prompts, and allows answering them. #[derive(Default)] pub struct InputRegistry { in_flight: RwLock>, } /// An opaque identifier for an input prompt. pub type Id = ulid::Ulid; /// A pending request for input. #[derive(Debug)] pub struct PendingInputRequest { /// Opaque identifier for this request. pub id: Id, /// Description of what type of input is needed, and human-readable prompt. pub request: Schema, sender: oneshot::Sender, } impl InputRegistry { /// Get the pending requests for input. pub async fn pending(&self) -> RwLockReadGuard<'_, [PendingInputRequest]> { RwLockReadGuard::map(self.in_flight.read().await, Vec::as_slice) } /// Answer a pending input request. /// # Errors /// If `id` isn't valid. /// If the request corresponding to `id` has already been answered. /// If the type of `completed` doesn't match up with the original request. pub async fn answer(&self, id: Id, completed: CompletedSchema) -> Result<(), InputAnswerError> { let entry = { let mut in_flight = self.in_flight.write().await; let len = in_flight.len(); in_flight.extract_if(0..len, |req| req.id == id).next() }; let Some(entry) = entry else { return Err(InputAnswerError::BadId); }; // ensure that completed request matches type if !completed.matches(&entry.request) { return Err(InputAnswerError::BadAnswer); } entry .sender .send(completed) .map_err(|_| InputAnswerError::AlreadyAnswered)?; Ok(()) } /// Request some input, blocking until we receive an answer or fail to do so. /// # Errors /// If the application is shutting down before we get an answer. pub async fn do_request( &self, req: Schema, ) -> Result { let (send, recv) = oneshot::channel(); self.in_flight.write().await.push(PendingInputRequest { id: Id::new(), request: req, sender: send, }); recv.await } /// Ask a yes or no question, with the given prompt. /// # Panics /// If the application is shutting down before we get an answer. pub async fn yes_no(&self, prompt: impl AsRef) -> bool { match self .do_request(Schema::YesNo(FieldDesc { short_description: prompt.as_ref().to_string(), })) .await .unwrap() { CompletedSchema::Boolean(s) => s, _ => unreachable!(), } } /// Ask for a free-form short text response, with the given prompt. /// # Panics /// If the application is shutting down before we get an answer. pub async fn text_box(&self, prompt: impl AsRef) -> String { match self .do_request(Schema::YesNo(FieldDesc { short_description: prompt.as_ref().to_string(), })) .await .unwrap() { CompletedSchema::String(s) => s, _ => unreachable!(), } } } /// An error encountered when trying to answer a request for input. #[derive(Debug, Clone, Copy, Error)] pub enum InputAnswerError { /// There's no pending input with that id. It may have already been answered, or have timed out. #[error("no pending input request with that id")] BadId, /// That pending input has already been answered, or hung up. #[error("input request has already been answered, or hung up already")] AlreadyAnswered, /// The pending input was found, but the answer given isn't in the shape required for it. #[error("response doesn't match the schema that was requested.")] BadAnswer, } impl std::fmt::Debug for InputRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InputRegistry").finish_non_exhaustive() } }