1#![doc = include_str!("../README.md")]
2
3#[cfg(all(
7 not(feature = "requestty-backend"),
8 not(feature = "egui-backend"),
9 not(feature = "dialoguer-backend"),
10 not(feature = "ratatui-backend"),
11))]
12compile_error!("derive-wizard requires a backend feature. Enable one backend feature.");
13
14pub mod answer;
15pub mod backend;
16pub mod field_path;
17
18#[cfg(feature = "typst-form")]
19pub mod typst_form;
20
21pub use answer::{AnswerError, AnswerValue, Answers};
22pub use backend::{BackendError, InterviewBackend, TestBackend};
23pub use derive_wizard_macro::*;
24pub use derive_wizard_types::SELECTED_ALTERNATIVE_KEY;
25pub use derive_wizard_types::interview;
26pub use field_path::FieldPath;
27
28#[cfg(feature = "requestty-backend")]
29pub use backend::requestty_backend::RequesttyBackend;
30
31#[cfg(feature = "dialoguer-backend")]
32pub use backend::dialoguer_backend::DialoguerBackend;
33
34#[cfg(feature = "egui-backend")]
35pub use backend::egui_backend::EguiBackend;
36
37#[cfg(feature = "ratatui-backend")]
38pub use backend::ratatui_backend::{RatatuiBackend, Theme as RatatuiTheme};
39
40#[cfg(feature = "ratatui-backend")]
41pub use ratatui::style::Color as RatatuiColor;
42
43pub trait Wizard: Sized {
44 fn interview() -> interview::Interview;
46
47 fn interview_with_suggestions(&self) -> interview::Interview;
49
50 fn from_answers(answers: &Answers) -> Result<Self, BackendError>;
52
53 fn validate_field(field: &str, value: &str, answers: &Answers) -> Result<(), String>;
56
57 fn wizard_builder() -> WizardBuilder<Self> {
59 WizardBuilder::new()
60 }
61
62 #[cfg(feature = "typst-form")]
87 fn to_typst_form(title: Option<&str>) -> String {
88 let interview = Self::interview();
89 crate::typst_form::generate_typst_form(&interview, title)
90 }
91}
92
93fn find_question_by_path<'a>(
100 questions: &'a mut [interview::Question],
101 path: &FieldPath,
102) -> Option<&'a mut interview::Question> {
103 let segments = path.segments();
104
105 if segments.is_empty() {
106 return None;
107 }
108
109 let full_path = path.to_path();
111
112 let full_path_idx = questions.iter().position(|q| q.name() == full_path);
114 if let Some(idx) = full_path_idx {
115 return Some(&mut questions[idx]);
116 }
117
118 if segments.len() == 1 {
120 let idx = questions.iter().position(|q| q.name() == segments[0])?;
121 return Some(&mut questions[idx]);
122 }
123
124 let first = &segments[0];
126 let rest = FieldPath::new(segments[1..].to_vec());
127
128 for question in questions.iter_mut() {
129 if question.name() == first {
131 if let interview::QuestionKind::Sequence(nested_questions) = question.kind_mut() {
133 return find_question_by_path(nested_questions, &rest);
135 }
136 }
137 }
138
139 None
140}
141
142#[derive(Default)]
144pub struct WizardBuilder<T: Wizard> {
145 suggestions: Option<T>,
146 partial_suggestions: std::collections::HashMap<
147 FieldPath,
148 derive_wizard_types::suggested_answer::SuggestedAnswer,
149 >,
150 partial_assumptions: std::collections::HashMap<FieldPath, derive_wizard_types::AssumedAnswer>,
151 backend: Option<Box<dyn InterviewBackend>>,
152}
153
154impl<T: Wizard> WizardBuilder<T> {
155 pub fn new() -> Self {
157 Self {
158 suggestions: None,
159 partial_suggestions: std::collections::HashMap::new(),
160 partial_assumptions: std::collections::HashMap::new(),
161 backend: None,
162 }
163 }
164
165 pub fn with_suggestions(mut self, suggestions: T) -> Self {
167 self.suggestions = Some(suggestions);
168 self
169 }
170
171 pub fn suggest_field(
175 mut self,
176 field: impl Into<FieldPath>,
177 value: impl Into<derive_wizard_types::suggested_answer::SuggestedAnswer>,
178 ) -> Self {
179 self.partial_suggestions.insert(field.into(), value.into());
180 self
181 }
182
183 pub fn assume_field(
187 mut self,
188 field: impl Into<FieldPath>,
189 value: impl Into<derive_wizard_types::AssumedAnswer>,
190 ) -> Self {
191 self.partial_assumptions.insert(field.into(), value.into());
192 self
193 }
194
195 pub fn with_backend<B: InterviewBackend + 'static>(mut self, backend: B) -> Self {
197 self.backend = Some(Box::new(backend));
198 self
199 }
200
201 #[cfg(feature = "requestty-backend")]
203 pub fn build(self) -> Result<T, BackendError> {
204 use crate::backend::requestty_backend::RequesttyBackend;
205
206 let backend = self.backend.unwrap_or_else(|| Box::new(RequesttyBackend));
207
208 let mut interview = self
209 .suggestions
210 .as_ref()
211 .map_or_else(T::interview, |suggestions| {
212 suggestions.interview_with_suggestions()
213 });
214
215 for (field_path, value) in self.partial_suggestions {
217 if let Some(question) = find_question_by_path(&mut interview.sections, &field_path) {
218 question.set_suggestion(value);
219 }
220 }
221
222 for (field_path, value) in self.partial_assumptions {
224 if let Some(question) = find_question_by_path(&mut interview.sections, &field_path) {
225 question.set_assumption(value);
226 }
227 }
228
229 let answers = backend.execute_with_validator(&interview, &T::validate_field)?;
230 T::from_answers(&answers)
231 }
232
233 #[cfg(not(feature = "requestty-backend"))]
235 pub fn build(self) -> Result<T, BackendError> {
236 let backend = match self.backend {
237 Some(backend) => backend,
238 None => {
239 return Err(BackendError::Custom(
240 "No backend specified and requestty-backend feature is not enabled".to_string(),
241 ));
242 }
243 };
244
245 let mut interview = match &self.suggestions {
246 Some(suggestions) => suggestions.interview_with_suggestions(),
247 None => T::interview(),
248 };
249
250 for (field_path, value) in self.partial_suggestions {
252 if let Some(question) = find_question_by_path(&mut interview.sections, &field_path) {
253 question.set_suggestion(value);
254 }
255 }
256
257 for (field_path, value) in self.partial_assumptions {
259 if let Some(question) = find_question_by_path(&mut interview.sections, &field_path) {
260 question.set_assumption(value);
261 }
262 }
263
264 let answers = backend.execute_with_validator(&interview, &T::validate_field)?;
265 T::from_answers(&answers)
266 }
267}