Rust library to generate static websites
at fix/misc-errors 380 lines 12 kB view raw
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}