Skip to main content

pipi/
wizard.rs

1//! This module provides interactive utilities for setting up application
2//! configurations based on user input.
3
4use clap::ValueEnum;
5use colored::Colorize;
6use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
7use serde::{Deserialize, Serialize};
8use strum::{Display, EnumIter, IntoEnumIterator};
9
10use crate::Error;
11
12#[derive(
13    Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum,
14)]
15pub enum Template {
16    #[default]
17    #[strum(to_string = "Saas App with server side rendering")]
18    SaasServerSideRendering,
19    #[strum(to_string = "Saas App with client side rendering")]
20    SaasClientSideRendering,
21    #[strum(to_string = "Rest API (with DB and user auth)")]
22    RestApi,
23    #[strum(to_string = "lightweight-service (minimal, only controllers and views)")]
24    Lightweight,
25    #[strum(to_string = "Advanced")]
26    Advanced,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
30pub enum OptionsList {
31    #[serde(rename = "db")]
32    DB,
33    #[serde(rename = "bg")]
34    Background,
35    #[serde(rename = "assets")]
36    Assets,
37}
38
39#[derive(
40    Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum,
41)]
42pub enum DBOption {
43    #[default]
44    #[serde(rename = "sqlite")]
45    Sqlite,
46    #[serde(rename = "pg")]
47    Postgres,
48    #[serde(rename = "none")]
49    None,
50}
51
52impl DBOption {
53    #[must_use]
54    pub const fn enable(&self) -> bool {
55        !matches!(self, Self::None)
56    }
57
58    #[must_use]
59    pub fn user_message(&self) -> Option<String> {
60        match self {
61            Self::Postgres => Some(format!(
62                "{}: You've selected `{}` as your DB provider (you should have a postgres \
63                 instance to connect to)",
64                "database".underline(),
65                "postgres".yellow()
66            )),
67            Self::Sqlite | Self::None => None,
68        }
69    }
70
71    #[must_use]
72    pub const fn endpoint_config(&self) -> &str {
73        match self {
74            Self::Sqlite => "sqlite://NAME_ENV.sqlite?mode=rwc",
75            Self::Postgres => "postgres://pipi:pipi@localhost:5432/NAME_ENV",
76            Self::None => "",
77        }
78    }
79}
80
81#[derive(
82    Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum,
83)]
84pub enum BackgroundOption {
85    #[default]
86    #[strum(to_string = "Async (in-process tokio async tasks)")]
87    #[serde(rename = "BackgroundAsync")]
88    Async,
89    #[strum(to_string = "Queue (standalone workers using Redis)")]
90    #[serde(rename = "BackgroundQueue")]
91    Queue,
92    #[strum(to_string = "Blocking (run tasks in foreground)")]
93    #[serde(rename = "ForegroundBlocking")]
94    Blocking,
95}
96
97impl BackgroundOption {
98    #[must_use]
99    pub fn user_message(&self) -> Option<String> {
100        match self {
101            Self::Queue => Some(format!(
102                "{}: You've selected `{}` for your background worker configuration (you should \
103                 have a Redis/valkey instance to connect to)",
104                "workers".underline(),
105                "queue".yellow()
106            )),
107            Self::Blocking => Some(format!(
108                "{}: You've selected `{}` for your background worker configuration. Your workers \
109                 configuration will BLOCK REQUESTS until a task is done.",
110                "workers".underline(),
111                "blocking".yellow()
112            )),
113            Self::Async => None,
114        }
115    }
116
117    #[must_use]
118    pub const fn prompt_view(&self) -> &str {
119        match self {
120            Self::Async => "Async",
121            Self::Queue => "BackgroundQueue",
122            Self::Blocking => "ForegroundBlocking",
123        }
124    }
125}
126
127#[derive(
128    Debug, Clone, Deserialize, Serialize, EnumIter, Display, Default, PartialEq, Eq, ValueEnum,
129)]
130pub enum AssetsOption {
131    #[default]
132    #[strum(to_string = "Server (configures server-rendered views)")]
133    #[serde(rename = "server")]
134    Serverside,
135    #[strum(to_string = "Client (configures assets for frontend serving)")]
136    #[serde(rename = "client")]
137    Clientside,
138    #[strum(to_string = "None")]
139    #[serde(rename = "none")]
140    None,
141}
142
143impl AssetsOption {
144    #[must_use]
145    pub const fn enable(&self) -> bool {
146        !matches!(self, Self::None)
147    }
148
149    #[must_use]
150    pub fn user_message(&self) -> Option<String> {
151        match self {
152            Self::Clientside => Some(format!(
153                "{}: You've selected `{}` for your asset serving configuration.\n\nNext step, \
154                 build your frontend:\n  $ cd {}\n  $ npm install && npm run build\n",
155                "assets".underline(),
156                "clientside".yellow(),
157                "frontend/".yellow()
158            )),
159            Self::Serverside | Self::None => None,
160        }
161    }
162}
163
164#[derive(Debug, Clone, Default)]
165/// Represents internal placeholders to be replaced.
166pub struct ArgsPlaceholder {
167    pub db: Option<DBOption>,
168    pub bg: Option<BackgroundOption>,
169    pub assets: Option<AssetsOption>,
170}
171
172/// Holds the user's configuration selections.
173pub struct Selections {
174    pub db: DBOption,
175    pub background: BackgroundOption,
176    pub asset: AssetsOption,
177}
178
179impl Selections {
180    #[must_use]
181    pub fn message(&self) -> Vec<String> {
182        let mut res = Vec::new();
183        if let Some(m) = self.db.user_message() {
184            res.push(m);
185        }
186        if let Some(m) = self.background.user_message() {
187            res.push(m);
188        }
189        if let Some(m) = self.asset.user_message() {
190            res.push(m);
191        }
192        res
193    }
194}
195
196/// Prompts the user to enter an application name, with optional pre-set name
197/// input. Validates the name to ensure compliance with required naming rules.
198///
199/// # Errors
200/// when could not show user selection
201pub fn app_name(name: Option<String>) -> crate::Result<String> {
202    if let Some(app_name) = name {
203        validate_app_name(app_name.as_str()).map_err(|err| Error::msg(err.to_string()))?;
204        Ok(app_name)
205    } else {
206        let res = Input::with_theme(&ColorfulTheme::default())
207            .with_prompt("❯ App name?")
208            .default("myapp".into())
209            .validate_with(|input: &String| {
210                if let Err(err) = validate_app_name(input) {
211                    Err(err.to_string())
212                } else {
213                    Ok(())
214                }
215            })
216            .interact_text()?;
217        Ok(res)
218    }
219}
220
221/// Warns the user if the current directory is inside a Git repository and
222/// prompts them to confirm whether they wish to proceed. If declined, an error
223/// is returned.
224///
225/// # Errors
226/// when could not show user selection or user chose not continue
227pub fn warn_if_in_git_repo() -> crate::Result<()> {
228    let answer = Confirm::with_theme(&ColorfulTheme::default())
229        .with_prompt("❯ You are inside a git repository. Do you wish to continue?")
230        .default(false)
231        .interact()?;
232
233    if answer {
234        Ok(())
235    } else {
236        Err(Error::msg(
237            "Aborted: You've chose not to continue.".to_string(),
238        ))
239    }
240}
241
242/// Validates the application name.
243fn validate_app_name(app_name: &str) -> Result<(), &str> {
244    if app_name.is_empty() {
245        return Err("app name could not be empty");
246    }
247
248    let mut chars = app_name.chars();
249    if let Some(ch) = chars.next() {
250        if ch.is_ascii_digit() {
251            return Err("the name cannot start with a digit");
252        }
253        if !(unicode_xid::UnicodeXID::is_xid_start(ch) || ch == '_') {
254            return Err(
255                "the first character must be a Unicode XID start character (most letters or `_`)",
256            );
257        }
258    }
259    for ch in chars {
260        if !(unicode_xid::UnicodeXID::is_xid_continue(ch) || ch == '-') {
261            return Err(
262                "characters must be Unicode XID characters (numbers, `-`, `_`, or most letters)",
263            );
264        }
265    }
266    Ok(())
267}
268
269/// Provides a selection menu to the user for choosing from a list of options.
270/// Returns the selected option or a default if selection fails.
271fn select_option<T>(text: &str, options: &[T]) -> crate::Result<T>
272where
273    T: Default + ToString + Clone,
274{
275    let selection = Select::with_theme(&ColorfulTheme::default())
276        .with_prompt(text)
277        .default(0)
278        .items(options)
279        .interact()?;
280    Ok(options.get(selection).cloned().unwrap_or_default())
281}
282
283/// start wizard
284///
285/// # Errors
286/// when could not show user selection or user chose not continue
287pub fn start(args: &ArgsPlaceholder) -> crate::Result<Selections> {
288    // user provided everything via flags so no need to prompt, just return
289    if let (Some(db), Some(bg), Some(assets)) =
290        (args.db.clone(), args.bg.clone(), args.assets.clone())
291    {
292        return Ok(Selections {
293            db,
294            background: bg,
295            asset: assets,
296        });
297    }
298
299    let template = select_option(
300        "❯ What would you like to build?",
301        &Template::iter().collect::<Vec<_>>(),
302    )?;
303
304    match template {
305        Template::Lightweight => Ok(Selections {
306            db: DBOption::None,
307            background: BackgroundOption::Async,
308            asset: AssetsOption::None,
309        }),
310        Template::RestApi => Ok(Selections {
311            db: select_db(args)?,
312            background: select_background(args, None)?,
313            asset: AssetsOption::None,
314        }),
315        Template::SaasServerSideRendering => Ok(Selections {
316            db: select_db(args)?,
317            background: select_background(args, None)?,
318            asset: AssetsOption::Serverside,
319        }),
320        Template::SaasClientSideRendering => Ok(Selections {
321            db: select_db(args)?,
322            background: select_background(args, None)?,
323            asset: AssetsOption::Clientside,
324        }),
325        Template::Advanced => {
326            let db = select_db(args)?;
327            Ok(Selections {
328                db,
329                background: select_background(args, None)?,
330                asset: select_asset(args)?,
331            })
332        }
333    }
334}
335
336/// Prompts the user to select a database option if none is provided in the
337/// arguments.
338fn select_db(args: &ArgsPlaceholder) -> crate::Result<DBOption> {
339    let dboption = if let Some(dboption) = args.db.clone() {
340        dboption
341    } else {
342        select_option(
343            "❯ Select a DB Provider",
344            &DBOption::iter().collect::<Vec<_>>(),
345        )?
346    };
347    Ok(dboption)
348}
349
350/// Prompts the user to select a background worker option if none is provided in
351/// the arguments.
352fn select_background(
353    args: &ArgsPlaceholder,
354    filters: Option<&Vec<BackgroundOption>>,
355) -> crate::Result<BackgroundOption> {
356    let bgopt = if let Some(bgopt) = args.bg.clone() {
357        bgopt
358    } else {
359        let available_options = BackgroundOption::iter()
360            .filter(|opt| filters.as_ref().is_none_or(|f| !f.contains(opt)))
361            .collect::<Vec<_>>();
362
363        select_option("❯ Select your background worker type", &available_options)?
364    };
365    Ok(bgopt)
366}
367
368/// Prompts the user to select an asset configuration if none is provided in the
369/// arguments.
370fn select_asset(args: &ArgsPlaceholder) -> crate::Result<AssetsOption> {
371    let assetopt = if let Some(assetopt) = args.assets.clone() {
372        assetopt
373    } else {
374        select_option(
375            "❯ Select an asset serving configuration",
376            &AssetsOption::iter().collect::<Vec<_>>(),
377        )?
378    };
379    Ok(assetopt)
380}