shulkerscript_cli/subcommands/
init.rs

1use 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    /// The path of the folder to initialize in.
25    #[arg(default_value = ".")]
26    pub path: PathBuf,
27    /// The name of the project.
28    #[arg(short, long)]
29    pub name: Option<String>,
30    /// The description of the project.
31    #[arg(short, long)]
32    pub description: Option<String>,
33    /// The pack format version.
34    #[arg(short, long, value_name = "FORMAT", visible_alias = "format")]
35    pub pack_format: Option<u8>,
36    /// The path of the icon file.
37    #[arg(short, long = "icon", value_name = "PATH")]
38    pub icon_path: Option<PathBuf>,
39    /// Force initialization even if the directory is not empty.
40    #[arg(short, long)]
41    pub force: bool,
42    /// The version control system to initialize. [default: git]
43    #[arg(long)]
44    pub vcs: Option<VersionControlSystem>,
45    /// Enable verbose output.
46    #[arg(short, long)]
47    pub verbose: bool,
48    /// Enable batch mode.
49    ///
50    /// In batch mode, the command will not prompt the user for input and
51    /// will use the default values instead if possible or fail.
52    #[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 the pack.toml file
111    create_pack_config(verbose, path, name, description, pack_format)?;
112
113    // Create the pack.png file
114    create_pack_png(path, args.icon_path.as_deref(), verbose)?;
115
116    // Create the src directory
117    let src_path = path.join("src");
118    create_dir(&src_path, verbose)?;
119
120    // Create the main.shu file
121    create_main_file(
122        path,
123        &name_to_namespace(name.unwrap_or(PackConfig::DEFAULT_NAME)),
124        verbose,
125    )?;
126
127    // Initialize the version control system
128    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 the pack.toml file
313    create_pack_config(
314        verbose,
315        path,
316        name.as_deref(),
317        description.as_deref(),
318        pack_format,
319    )?;
320
321    // Create the pack.png file
322    create_pack_png(path, icon_path.as_deref(), verbose)?;
323
324    // Create the src directory
325    let src_path = path.join("src");
326    create_dir(&src_path, verbose)?;
327
328    // Create the main.shu file
329    create_main_file(
330        path,
331        &name_to_namespace(&name.unwrap_or(Cow::Borrowed("shulkerscript-pack"))),
332        verbose,
333    )?;
334
335    // Initialize the version control system
336    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    // Load the default config
353    let mut content = ProjectConfig::default();
354    // Override the default values with the provided ones
355    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            // Initalize the Git repository
453            let repo = GitRepository::init(path)?;
454            repo.add_ignore_rule("/dist")?;
455
456            // Create the .gitignore file
457            create_gitignore(path, verbose)?;
458
459            // Create the initial commit
460            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            // Create the second commit with the template files
476            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}