1use 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)]
165pub struct ArgsPlaceholder {
167 pub db: Option<DBOption>,
168 pub bg: Option<BackgroundOption>,
169 pub assets: Option<AssetsOption>,
170}
171
172pub 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
196pub 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
221pub 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
242fn 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
269fn 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
283pub fn start(args: &ArgsPlaceholder) -> crate::Result<Selections> {
288 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
336fn 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
350fn 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
368fn 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}