shulkerscript_cli/subcommands/
init.rs1use std::{
2 borrow::Cow,
3 fmt::Display,
4 fs,
5 path::{Path, PathBuf},
6};
7
8use anyhow::Result;
9use clap::ValueEnum;
10use git2::{
11 IndexAddOption as GitIndexAddOption, Repository as GitRepository, Signature as GitSignature,
12};
13use inquire::validator::Validation;
14use path_absolutize::Absolutize;
15
16use crate::{
17 config::{PackConfig, ProjectConfig},
18 error::Error,
19 terminal_output::{print_error, print_info, print_success},
20};
21
22#[derive(Debug, clap::Args, Clone)]
23pub struct InitArgs {
24 #[arg(default_value = ".")]
26 pub path: PathBuf,
27 #[arg(short, long)]
29 pub name: Option<String>,
30 #[arg(short, long)]
32 pub description: Option<String>,
33 #[arg(short, long, value_name = "FORMAT", visible_alias = "format")]
35 pub pack_format: Option<u8>,
36 #[arg(short, long = "icon", value_name = "PATH")]
38 pub icon_path: Option<PathBuf>,
39 #[arg(short, long)]
41 pub force: bool,
42 #[arg(long)]
44 pub vcs: Option<VersionControlSystem>,
45 #[arg(short, long)]
47 pub verbose: bool,
48 #[arg(long)]
53 pub batch: bool,
54}
55
56#[derive(Debug, Clone, Copy, Default, ValueEnum)]
57pub enum VersionControlSystem {
58 #[default]
59 Git,
60 None,
61}
62
63impl Display for VersionControlSystem {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 VersionControlSystem::Git => write!(f, "git"),
67 VersionControlSystem::None => write!(f, "none"),
68 }
69 }
70}
71
72pub fn init(args: &InitArgs) -> Result<()> {
73 if args.batch {
74 initialize_batch(args)
75 } else {
76 initialize_interactive(args)
77 }
78}
79
80fn initialize_batch(args: &InitArgs) -> Result<()> {
81 let verbose = args.verbose;
82 let force = args.force;
83 let path = args.path.as_path();
84 let description = args.description.as_deref();
85 let pack_format = args.pack_format;
86 let vcs = args.vcs.unwrap_or(VersionControlSystem::Git);
87
88 if !path.exists() {
89 if force {
90 fs::create_dir_all(path)?;
91 } else {
92 print_error("The specified path does not exist.");
93 Err(Error::PathNotFoundError(path.to_path_buf()))?;
94 }
95 } else if !path.is_dir() {
96 print_error("The specified path is not a directory.");
97 Err(Error::NotDirectoryError(path.to_path_buf()))?;
98 } else if !force && path.read_dir()?.next().is_some() {
99 print_error("The specified directory is not empty.");
100 Err(Error::NonEmptyDirectoryError(path.to_path_buf()))?;
101 }
102
103 let name = args
104 .name
105 .as_deref()
106 .or_else(|| path.file_name().and_then(|os| os.to_str()));
107
108 print_info("Initializing a new Shulkerscript project in batch mode...");
109
110 create_pack_config(verbose, path, name, description, pack_format)?;
112
113 create_pack_png(path, args.icon_path.as_deref(), verbose)?;
115
116 let src_path = path.join("src");
118 create_dir(&src_path, verbose)?;
119
120 create_main_file(
122 path,
123 &name_to_namespace(name.unwrap_or(PackConfig::DEFAULT_NAME)),
124 verbose,
125 )?;
126
127 initalize_vcs(path, vcs, verbose)?;
129
130 print_success("Project initialized successfully.");
131
132 Ok(())
133}
134
135fn initialize_interactive(args: &InitArgs) -> Result<()> {
136 const ABORT_MSG: &str = "Project initialization interrupted. Aborting...";
137
138 let verbose = args.verbose;
139 let force = args.force;
140 let path = args.path.as_path();
141 let description = args.description.as_deref();
142 let pack_format = args.pack_format;
143
144 if !path.exists() {
145 if force {
146 fs::create_dir_all(path)?;
147 } else {
148 match inquire::Confirm::new(
149 "The specified path does not exist. Do you want to create it?",
150 )
151 .with_default(true)
152 .prompt()
153 {
154 Ok(true) => fs::create_dir_all(path)?,
155 Ok(false) | Err(_) => {
156 print_info(ABORT_MSG);
157 return Err(inquire::InquireError::OperationCanceled.into());
158 }
159 }
160 }
161 } else if !path.is_dir() {
162 print_error("The specified path is not a directory.");
163 Err(Error::NotDirectoryError(path.to_path_buf()))?
164 } else if !force && path.read_dir()?.next().is_some() {
165 match inquire::Confirm::new(
166 "The specified directory is not empty. Do you want to continue?",
167 )
168 .with_default(false)
169 .with_help_message("This may overwrite existing files in the directory.")
170 .prompt()
171 {
172 Ok(false) | Err(_) => {
173 print_info(ABORT_MSG);
174 return Err(inquire::InquireError::OperationCanceled.into());
175 }
176 Ok(true) => {}
177 }
178 }
179
180 let mut interrupted = false;
181
182 let name = args.name.as_deref().map(Cow::Borrowed).or_else(|| {
183 let default = path
184 .file_name()
185 .and_then(|os| os.to_str())
186 .unwrap_or(PackConfig::DEFAULT_NAME);
187
188 match inquire::Text::new("Enter the name of the project:")
189 .with_help_message("This will be the name of your datapack folder/zip file")
190 .with_default(default)
191 .prompt()
192 {
193 Ok(res) => Some(Cow::Owned(res)),
194 Err(_) => {
195 interrupted = true;
196 None
197 }
198 }
199 .or_else(|| {
200 path.file_name()
201 .and_then(|os| os.to_str().map(Cow::Borrowed))
202 })
203 });
204
205 if interrupted {
206 print_info(ABORT_MSG);
207 return Err(inquire::InquireError::OperationCanceled.into());
208 }
209
210 let description = description.map(Cow::Borrowed).or_else(|| {
211 match inquire::Text::new("Enter the description of the project:")
212 .with_help_message("This will be the description of your datapack, visible in the datapack selection screen")
213 .with_default(PackConfig::DEFAULT_DESCRIPTION)
214 .prompt() {
215 Ok(res) => Some(Cow::Owned(res)),
216 Err(_) => {
217 interrupted = true;
218 None
219 }
220 }
221 });
222
223 if interrupted {
224 print_info(ABORT_MSG);
225 return Err(inquire::InquireError::OperationCanceled.into());
226 }
227
228 let pack_format = pack_format.or_else(|| {
229 match inquire::Text::new("Enter the pack format:")
230 .with_help_message("This will determine the Minecraft version compatible with your pack, find more on the Minecraft wiki")
231 .with_default(PackConfig::DEFAULT_PACK_FORMAT.to_string().as_str())
232 .with_validator(|v: &str| Ok(
233 v.parse::<u8>()
234 .map(|_| Validation::Valid)
235 .unwrap_or(Validation::Invalid(
236 inquire::validator::ErrorMessage::Custom("Invalid pack format".to_string())))))
237 .prompt() {
238 Ok(res) => res.parse().ok(),
239 Err(_) => {
240 interrupted = true;
241 None
242 }
243 }
244 });
245
246 if interrupted {
247 print_info(ABORT_MSG);
248 return Err(inquire::InquireError::OperationCanceled.into());
249 }
250
251 let vcs = args.vcs.unwrap_or_else(|| {
252 match inquire::Select::new(
253 "Select the version control system:",
254 vec![VersionControlSystem::Git, VersionControlSystem::None],
255 )
256 .with_help_message("This will initialize a version control system")
257 .prompt()
258 {
259 Ok(res) => res,
260 Err(_) => {
261 interrupted = true;
262 VersionControlSystem::Git
263 }
264 }
265 });
266
267 if interrupted {
268 print_info(ABORT_MSG);
269 return Err(inquire::InquireError::OperationCanceled.into());
270 }
271
272 let icon_path = args.icon_path.as_deref().map(Cow::Borrowed).or_else(|| {
273 let autocompleter = crate::util::PathAutocomplete::new();
274 match inquire::Text::new("Enter the path of the icon file:")
275 .with_help_message(
276 "This will be the icon of your datapack, visible in the datapack selection screen [use \"-\" for default]",
277 )
278 .with_autocomplete(autocompleter)
279 .with_validator(|s: &str| {
280 if s == "-" {
281 Ok(Validation::Valid)
282 } else {
283 let path = Path::new(s);
284 if path.exists() && path.is_file() && path.extension().is_some_and(|ext| ext == "png") {
285 Ok(Validation::Valid)
286 } else {
287 Ok(Validation::Invalid(
288 inquire::validator::ErrorMessage::Custom("Invalid file path. Path must exist and point to a png".to_string()),
289 ))
290 }
291 }
292 })
293 .with_default("-")
294 .prompt()
295 {
296 Ok(res) if &res == "-" => None,
297 Ok(res) => Some(Cow::Owned(PathBuf::from(res))),
298 Err(_) => {
299 interrupted = true;
300 None
301 }
302 }
303 });
304
305 if interrupted {
306 print_info(ABORT_MSG);
307 return Err(inquire::InquireError::OperationCanceled.into());
308 }
309
310 print_info("Initializing a new Shulkerscript project...");
311
312 create_pack_config(
314 verbose,
315 path,
316 name.as_deref(),
317 description.as_deref(),
318 pack_format,
319 )?;
320
321 create_pack_png(path, icon_path.as_deref(), verbose)?;
323
324 let src_path = path.join("src");
326 create_dir(&src_path, verbose)?;
327
328 create_main_file(
330 path,
331 &name_to_namespace(&name.unwrap_or(Cow::Borrowed("shulkerscript-pack"))),
332 verbose,
333 )?;
334
335 initalize_vcs(path, vcs, verbose)?;
337
338 print_success("Project initialized successfully.");
339
340 Ok(())
341}
342
343fn create_pack_config(
344 verbose: bool,
345 base_path: &Path,
346 name: Option<&str>,
347 description: Option<&str>,
348 pack_format: Option<u8>,
349) -> Result<()> {
350 let path = base_path.join("pack.toml");
351
352 let mut content = ProjectConfig::default();
354 if let Some(name) = name {
356 content.pack.name = name.to_string();
357 }
358 if let Some(description) = description {
359 content.pack.description = description.to_string();
360 }
361 if let Some(pack_format) = pack_format {
362 content.pack.pack_format = pack_format;
363 }
364
365 fs::write(&path, toml::to_string_pretty(&content)?)?;
366 if verbose {
367 print_info(format!(
368 "Created pack.toml file at {}.",
369 path.absolutize()?.display()
370 ));
371 }
372 Ok(())
373}
374
375fn create_dir(path: &Path, verbose: bool) -> std::io::Result<()> {
376 if !path.exists() {
377 fs::create_dir(path)?;
378 if verbose {
379 print_info(format!(
380 "Created directory at {}.",
381 path.absolutize()?.display()
382 ));
383 }
384 }
385 Ok(())
386}
387
388fn create_gitignore(path: &Path, verbose: bool) -> std::io::Result<()> {
389 let gitignore = path.join(".gitignore");
390 fs::write(&gitignore, "/dist\n")?;
391 if verbose {
392 print_info(format!(
393 "Created .gitignore file at {}.",
394 gitignore.absolutize()?.display()
395 ));
396 }
397 Ok(())
398}
399
400fn create_pack_png(
401 project_path: &Path,
402 icon_path: Option<&Path>,
403 verbose: bool,
404) -> std::io::Result<()> {
405 let pack_png = project_path.join("pack.png");
406 if let Some(icon_path) = icon_path {
407 fs::copy(icon_path, &pack_png)?;
408 if verbose {
409 print_info(format!(
410 "Copied pack.png file from {} to {}.",
411 icon_path.absolutize()?.display(),
412 pack_png.absolutize()?.display()
413 ));
414 }
415 } else {
416 fs::write(&pack_png, include_bytes!("../../assets/default-icon.png"))?;
417 if verbose {
418 print_info(format!(
419 "Created pack.png file at {}.",
420 pack_png.absolutize()?.display()
421 ));
422 }
423 }
424 Ok(())
425}
426
427fn create_main_file(path: &Path, namespace: &str, verbose: bool) -> std::io::Result<()> {
428 let main_file = path.join("src").join("main.shu");
429 fs::write(
430 &main_file,
431 format!(
432 include_str!("../../assets/default-main.shu"),
433 namespace = namespace
434 ),
435 )?;
436 if verbose {
437 print_info(format!(
438 "Created main.shu file at {}.",
439 main_file.absolutize()?.display()
440 ));
441 }
442 Ok(())
443}
444
445fn initalize_vcs(path: &Path, vcs: VersionControlSystem, verbose: bool) -> Result<()> {
446 match vcs {
447 VersionControlSystem::None => Ok(()),
448 VersionControlSystem::Git => {
449 if verbose {
450 print_info("Initializing a new Git repository...");
451 }
452 let repo = GitRepository::init(path)?;
454 repo.add_ignore_rule("/dist")?;
455
456 create_gitignore(path, verbose)?;
458
459 let mut index = repo.index()?;
461 let oid = index.write_tree()?;
462 let tree = repo.find_tree(oid)?;
463 let signature = repo
464 .signature()
465 .unwrap_or(GitSignature::now("Shulkerscript CLI", "cli@shulkerscript")?);
466 repo.commit(
467 Some("HEAD"),
468 &signature,
469 &signature,
470 "Inital commit",
471 &tree,
472 &[],
473 )?;
474
475 let mut index = repo.index()?;
477 index.add_all(["."].iter(), GitIndexAddOption::DEFAULT, None)?;
478 index.write()?;
479 let oid = index.write_tree()?;
480 let tree = repo.find_tree(oid)?;
481 let parent = repo.head()?.peel_to_commit()?;
482 repo.commit(
483 Some("HEAD"),
484 &signature,
485 &signature,
486 "Add template files",
487 &tree,
488 &[&parent],
489 )?;
490
491 print_info("Initialized a new Git repository.");
492
493 Ok(())
494 }
495 }
496}
497
498fn name_to_namespace(name: &str) -> String {
499 const VALID_CHARS: &str = "0123456789abcdefghijklmnopqrstuvwxyz_-.";
500
501 name.to_lowercase()
502 .chars()
503 .filter_map(|c| {
504 if VALID_CHARS.contains(c) {
505 Some(c)
506 } else if c.is_ascii_uppercase() {
507 Some(c.to_ascii_lowercase())
508 } else if c.is_ascii_punctuation() {
509 Some('-')
510 } else if c.is_ascii_whitespace() {
511 Some('_')
512 } else {
513 None
514 }
515 })
516 .collect()
517}