Rust library to generate static websites
1use std::{
2 path::{Path, PathBuf},
3 process::Stdio,
4};
5
6use colored::Colorize;
7use flate2::read::GzDecoder;
8use inquire::{Confirm, Select, Text, validator::Validation};
9use quanta::Instant;
10use rand::seq::IndexedRandom;
11use spinach::{Color, Spinner};
12use toml_edit::DocumentMut;
13use tracing::{debug, error, info};
14
15mod names;
16mod render_config;
17use names::generate_directory_name;
18use render_config::get_render_config;
19
20use crate::logging::format_elapsed_time;
21
22const REPO_TAR_URL: &str = "https://api.github.com/repos/bruits/maudit/tarball/main";
23
24const INTROS: [&str; 6] = [
25 "Let the coronation begin.",
26 "The coronation shall begin.",
27 "A new era begins.",
28 "A new chapter unfolds.",
29 "A reign begins anew.",
30 "History is made today.",
31];
32
33pub fn start_new_project(dry_run: &bool) {
34 if *dry_run {
35 debug!("Dry run enabled");
36 }
37
38 inquire::set_global_render_config(get_render_config());
39
40 let cargo_search = std::process::Command::new("cargo")
41 .arg("search")
42 .arg("maudit")
43 .args(["--limit", "1"])
44 .current_dir(std::env::temp_dir()) // `cargo search` sometimes can fail in certain directories, so we use a temp dir
45 .output()
46 .expect("Failed to run cargo info maudit");
47
48 let maudit_version = if cargo_search.status.success() {
49 let output = String::from_utf8_lossy(&cargo_search.stdout).to_string();
50 format!(
51 "(v{})",
52 output
53 .lines()
54 .next()
55 .and_then(|line| {
56 let start = line.find('"')?;
57 let end = line[start + 1..].find('"')?;
58 Some(line[start + 1..start + 1 + end].to_string())
59 })
60 .unwrap_or_else(|| "unknown".to_string())
61 )
62 } else {
63 "".to_string()
64 };
65
66 println!();
67 match maudit_version.is_empty() {
68 true => {
69 info!(name: "SKIP_FORMAT", "👑 {} {}!", "Welcome to".bold(), "Maudit".red().to_string().bold(), )
70 }
71 false => {
72 info!(name: "SKIP_FORMAT", "👑 {} {}! {}", "Welcome to".bold(), "Maudit".red().to_string().bold(), maudit_version.dimmed())
73 }
74 }
75
76 let rng = &mut rand::rng();
77 let intro = INTROS.choose(rng).unwrap();
78 info!(name: "SKIP_FORMAT", " {}", intro.dimmed());
79 println!();
80
81 let directory_name = format!("./{}", generate_directory_name(rng));
82 let project_path = Text::new("Where should we create the project?")
83 .with_formatter(&|i| {
84 if i.is_empty() {
85 return directory_name.clone();
86 }
87
88 i.to_owned()
89 })
90 .with_validators(&[
91 Box::new(|s: &str| {
92 // Don't check if the directory already exists if the user wants to use the current directory
93 if s == "." {
94 return Ok(Validation::Valid);
95 }
96
97 if std::path::Path::new(&s).exists() {
98 Ok(Validation::Invalid(
99 "A directory with this name already exists".into(),
100 ))
101 } else {
102 Ok(Validation::Valid)
103 }
104 }),
105 Box::new(|s: &str| {
106 if has_invalid_filepath_chars(s) {
107 Ok(Validation::Invalid(
108 "The directory name contains invalid characters".into(),
109 ))
110 } else {
111 Ok(Validation::Valid)
112 }
113 }),
114 ])
115 .with_placeholder(&directory_name)
116 .prompt();
117
118 let project_path = match project_path {
119 Ok(path) => {
120 let path = if path.is_empty() {
121 directory_name
122 } else {
123 path
124 };
125
126 PathBuf::from(path)
127 }
128 Err(_) => {
129 println!();
130 return;
131 }
132 };
133
134 let templates: Vec<&str> = vec!["Blog", "Basics", "Empty"];
135 let template = Select::new("Which template would you like to use?", templates).prompt();
136
137 let template = match template {
138 Ok(template) => template.to_ascii_lowercase(),
139 Err(_) => {
140 println!();
141 return;
142 }
143 };
144
145 let git = Confirm::new("Do you want to initialize a git repository?")
146 .with_default(true)
147 .prompt();
148
149 let git = match git {
150 Ok(git) => git,
151 Err(_) => {
152 println!();
153 return;
154 }
155 };
156
157 // Do the steps
158 println!();
159
160 // Create the project directory
161 let directory_spinner = Spinner::new(" Creating directory")
162 .symbols(vec!["◐", "◓", "◑", "◒"])
163 .start();
164
165 let start_time = Instant::now();
166 if !dry_run {
167 std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
168 }
169 let elasped_time = format_elapsed_time(start_time.elapsed(), &Default::default());
170
171 directory_spinner
172 .text(&format!(" Created directory {}", elasped_time))
173 .symbol("●")
174 .color(Color::Green)
175 .stop();
176
177 let template_spinner = Spinner::new(" Downloading template")
178 .symbols(vec!["◐", "◓", "◑", "◒"])
179 .start();
180
181 let start_time = Instant::now();
182 if !dry_run {
183 download_and_unpack_template(&template, &project_path)
184 .expect("Failed to download template");
185 }
186 let elasped_time = format_elapsed_time(start_time.elapsed(), &Default::default());
187
188 template_spinner
189 .text(&format!(" Downloaded template {}", elasped_time))
190 .symbol("●")
191 .color(Color::Green)
192 .stop();
193
194 if git {
195 let git_spinner = Spinner::new(" Initializing git repository")
196 .symbols(vec!["◐", "◓", "◑", "◒"])
197 .start();
198
199 let start_time = Instant::now();
200
201 let init_result = if !dry_run {
202 init_git_repo(&project_path, dry_run)
203 } else {
204 Ok(())
205 };
206
207 let elasped_time = format_elapsed_time(start_time.elapsed(), &Default::default());
208
209 match init_result {
210 Ok(_) => git_spinner
211 .text(&format!(" Initialized git repository {}", elasped_time))
212 .symbol("●")
213 .color(Color::Green)
214 .stop(),
215 Err(e) => {
216 git_spinner
217 .text(" Failed to initialize git repository")
218 .failure();
219 eprintln!("{}", e);
220 }
221 }
222 }
223
224 println!();
225
226 info!(name: "SKIP_FORMAT", "👑 {} {}! Next steps:", "Project created".bold(), "successfully".green().to_string().bold());
227 println!();
228
229 let enter_directory = if project_path.to_string_lossy() != "." {
230 format!(
231 "1. Run {} to enter your project's directory.\n2. ",
232 format!("cd {}", project_path.display())
233 .bold()
234 .bright_blue()
235 .underline()
236 )
237 } else {
238 " ".to_string()
239 };
240
241 info!(
242 name: "SKIP_FORMAT",
243 "{}Run {} to start the development server, {} to stop it.",
244 enter_directory,
245 "maudit dev".bold().bright_blue().underline(),
246 "CTRL+C".bright_blue()
247 );
248 println!();
249
250 info!(name: "SKIP_FORMAT", " Visit {} for more information on using Maudit.", "https://maudit.org/docs".bold().bright_magenta().underline());
251 info!(name: "SKIP_FORMAT", " Need a hand? Find us at {}.", "https://maudit.org/chat".bold().bright_magenta().underline());
252}
253
254fn download_and_unpack_template(
255 template: &str,
256 project_path: &Path,
257) -> Result<(), Box<dyn std::error::Error>> {
258 let tarball = ureq::get(REPO_TAR_URL)
259 .call()
260 .map_err(|e| format!("Failed to download template: {}", e))?;
261
262 if !tarball.status().is_success() {
263 return Err("Failed to download template".into());
264 }
265
266 let (_, mut body) = tarball.into_parts();
267
268 let archive = GzDecoder::new(body.as_reader());
269
270 // Uncomment to test with a local tarball
271 //let archive = std::fs::File::open("project.tar").unwrap();
272
273 let mut archive = tar::Archive::new(archive);
274
275 let example_path = format!("examples/{}", template);
276 for entry in archive.entries()? {
277 let mut entry = entry?;
278 let path = entry.path()?.to_string_lossy().to_string();
279
280 if let Some(index) = path.find(&example_path).map(|i| i + example_path.len() + 1) {
281 let dest_path = project_path.join(&path[index..]);
282 entry.unpack(dest_path)?;
283 }
284 }
285
286 // Edit the Cargo.toml file
287 let cargo_toml_path = project_path.join("Cargo.toml");
288 match std::fs::read_to_string(&cargo_toml_path) {
289 Ok(content) => {
290 let mut cargo_toml = content.parse::<DocumentMut>().expect("invalid doc");
291
292 let project_path = project_path
293 .canonicalize()
294 .expect("Failed to canonicalize project path");
295 if let Some(project_name) = project_path.file_name().and_then(|name| name.to_str()) {
296 cargo_toml["package"]["name"] = toml_edit::value(project_name);
297
298 if let toml_edit::Item::Value(v) =
299 &cargo_toml["package"]["metadata"]["maudit"]["intended_version"]
300 {
301 cargo_toml["dependencies"]["maudit"] = toml_edit::value(v.clone());
302 }
303
304 cargo_toml["package"]["metadata"] = toml_edit::Item::None;
305
306 if let Err(e) = std::fs::write(&cargo_toml_path, cargo_toml.to_string()) {
307 error!("Failed to write Cargo.toml file: {}", e);
308 }
309 } else {
310 error!("Failed to determine project name from path");
311 }
312 }
313 Err(e) => {
314 error!("Failed to read Cargo.toml file: {}", e);
315 }
316 }
317
318 Ok(())
319}
320
321fn init_git_repo(project_path: &PathBuf, dry_run: &bool) -> Result<(), String> {
322 if !dry_run {
323 let git_init = std::process::Command::new("git")
324 .arg("init")
325 .arg(project_path)
326 .stdout(Stdio::null())
327 .stderr(Stdio::null())
328 .status()
329 .map_err(|e| format!("Failed to run git init: {}", e))?
330 .success();
331
332 if !git_init {
333 return Err("Failed to initialize git repository".to_string());
334 }
335
336 let git_add = std::process::Command::new("git")
337 .arg("add")
338 .arg("-A")
339 .current_dir(project_path)
340 .stdout(Stdio::null())
341 .stderr(Stdio::null())
342 .status()
343 .map_err(|e| format!("Failed to run git add: {}", e))?
344 .success();
345
346 if !git_add {
347 return Err("Failed to add initial changes".to_string());
348 }
349
350 let git_commit = std::process::Command::new("git")
351 .arg("commit")
352 .arg("-m")
353 .arg("Initial commit")
354 .current_dir(project_path)
355 .stdout(Stdio::null())
356 .stderr(Stdio::null())
357 .status()
358 .map_err(|e| format!("Failed to run git commit: {}", e))?
359 .success();
360
361 if !git_commit {
362 return Err("Failed to commit initial changes".to_string());
363 }
364 }
365
366 Ok(())
367}
368
369fn has_invalid_filepath_chars(s: &str) -> bool {
370 s.chars().any(|c| {
371 c == '\\'
372 || c == ':'
373 || c == '*'
374 || c == '?'
375 || c == '"'
376 || c == '<'
377 || c == '>'
378 || c == '|'
379 })
380}