derive_wizard/
lib.rs

1#![doc = include_str!("../README.md")]
2
3// Require at least one interactive backend feature. This forces users who disable
4// the default `requestty-backend` to opt into another backend instead of getting
5// a runtime error later.
6#[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    /// Get the interview structure for this type
45    fn interview() -> interview::Interview;
46
47    /// Get the interview structure with suggested values from this instance
48    fn interview_with_suggestions(&self) -> interview::Interview;
49
50    /// Build this type from collected answers
51    fn from_answers(answers: &Answers) -> Result<Self, BackendError>;
52
53    /// Validate a field value
54    /// This is called by backends during execution to validate user input
55    fn validate_field(field: &str, value: &str, answers: &Answers) -> Result<(), String>;
56
57    /// Create a builder for this wizard
58    fn wizard_builder() -> WizardBuilder<Self> {
59        WizardBuilder::new()
60    }
61
62    /// Generate a Typst form (.typ file) from the interview structure
63    ///
64    /// This method is only available when the `typst-form` feature is enabled.
65    /// It returns a String containing valid Typst markup that can be saved as a .typ file
66    /// and compiled to a professional-looking PDF form.
67    ///
68    /// Note: Typst does not currently support interactive fillable PDF forms. The generated
69    /// PDF is a static, print-ready form that can be filled out by hand or with PDF annotation tools.
70    ///
71    /// # Example
72    /// ```ignore
73    /// use derive_wizard::Wizard;
74    ///
75    /// #[derive(Wizard)]
76    /// struct Person {
77    ///     name: String,
78    ///     age: i64,
79    /// }
80    ///
81    /// let typst_markup = Person::to_typst_form(Some("Registration Form"));
82    /// std::fs::write("form.typ", &typst_markup).unwrap();
83    ///
84    /// // Compile to PDF using: typst compile form.typ
85    /// ```
86    #[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
93/// Helper function to recursively find a question by field path.
94///
95/// This searches through the interview hierarchy, navigating into
96/// nested `QuestionKind::Sequence` to find the target question.
97/// Questions can be named either hierarchically (separate questions)
98/// or with dot-separated paths (e.g., "address.street").
99fn 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    // Try to find by full dot-separated path first (flat question naming)
110    let full_path = path.to_path();
111
112    // Check if any question matches the full path
113    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 it's a single segment, search at this level
119    if segments.len() == 1 {
120        let idx = questions.iter().position(|q| q.name() == segments[0])?;
121        return Some(&mut questions[idx]);
122    }
123
124    // Multi-segment path: find the first segment at this level
125    let first = &segments[0];
126    let rest = FieldPath::new(segments[1..].to_vec());
127
128    for question in questions.iter_mut() {
129        // If this question's name matches the first segment
130        if question.name() == first {
131            // Check if it's a Sequence kind (nested struct)
132            if let interview::QuestionKind::Sequence(nested_questions) = question.kind_mut() {
133                // Recursively search in the nested questions
134                return find_question_by_path(nested_questions, &rest);
135            }
136        }
137    }
138
139    None
140}
141
142/// Builder for configuring and executing a wizard
143#[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    /// Create a new wizard builder
156    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    /// Set suggested values for the wizard
166    pub fn with_suggestions(mut self, suggestions: T) -> Self {
167        self.suggestions = Some(suggestions);
168        self
169    }
170
171    /// Suggest a specific field value. The question will still be asked but with a pre-filled default.
172    ///
173    /// For nested fields, use the `field!` macro.
174    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    /// Assume a specific field value. The question for this field will be skipped.
184    ///
185    /// For nested fields, use the `field!` macro.
186    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    /// Set a custom backend
196    pub fn with_backend<B: InterviewBackend + 'static>(mut self, backend: B) -> Self {
197        self.backend = Some(Box::new(backend));
198        self
199    }
200
201    /// Execute the wizard and return the result
202    #[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        // Apply partial suggestions
216        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        // Apply partial assumptions
223        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    /// Execute the wizard and return the result (no default backend required)
234    #[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        // Apply partial suggestions
251        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        // Apply partial assumptions
258        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}