diff --git a/Cargo.toml b/Cargo.toml index 76d9425..3c2eee1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ watch = ["dep:notify-debouncer-mini", "dep:ctrlc"] zip = ["shulkerbox/zip"] [dependencies] -clap = { version = "4.5.4", features = ["derive", "env"] } +clap = { version = "4.5.4", features = ["derive", "env", "deprecated"] } colored = "2.1.0" serde = { version = "1.0.197", features = ["derive"] } thiserror = "1.0.58" @@ -39,3 +39,6 @@ notify-debouncer-mini = { version = "0.4.1", default-features = false, optional ctrlc = { version = "3.4.4", optional = true } tracing = "0.1.40" tracing-subscriber = "0.3.18" +# waiting for pull request to be merged +inquire = { git = "https://github.com/moritz-hoelting/rust-inquire.git", branch = "main", package = "inquire" } +camino = "1.1.7" diff --git a/README.md b/README.md index 830cd67..d755ba7 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ Where [PATH] is the path of the folder to initialize in [default: `.`] Options: - `--name ` The name of the project - `--description ` The description of the project -- `--pack-format ` The pack format version +- `--pack-format ` The pack format version +- `--icon ` The path to the icon file, leave empty for default icon - `--force` Force initialization even if the directory is not empty +- `--batch` Do not prompt for input, use default values instead if possible or fail ### Build a project ```bash diff --git a/src/config.rs b/src/config.rs index 8fa2bc1..db7ac86 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,30 +12,28 @@ pub struct ProjectConfig { pub struct PackConfig { pub name: String, pub description: String, - #[serde( - rename = "format", - alias = "pack_format", - default = "default_pack_format" - )] + #[serde(rename = "format", alias = "pack_format")] pub pack_format: u8, pub version: String, } +impl PackConfig { + pub const DEFAULT_NAME: &'static str = "shulkerscript-pack"; + pub const DEFAULT_DESCRIPTION: &'static str = "A Minecraft datapack created with shulkerscript"; + pub const DEFAULT_PACK_FORMAT: u8 = 48; +} + impl Default for PackConfig { fn default() -> Self { Self { - name: "shulkerscript-pack".to_string(), - description: "A Minecraft datapack created with shulkerscript".to_string(), - pack_format: 26, + name: Self::DEFAULT_NAME.to_string(), + description: Self::DEFAULT_DESCRIPTION.to_string(), + pack_format: Self::DEFAULT_PACK_FORMAT, version: "0.1.0".to_string(), } } } -fn default_pack_format() -> u8 { - 26 -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompilerConfig { /// The path of a folder which files and subfolders will be copied to the root of the datapack. diff --git a/src/main.rs b/src/main.rs index 8d972b7..73df006 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod error; mod subcommands; mod terminal_output; +mod util; use std::process::ExitCode; diff --git a/src/subcommands/clean.rs b/src/subcommands/clean.rs index 83d6f34..012516d 100644 --- a/src/subcommands/clean.rs +++ b/src/subcommands/clean.rs @@ -21,7 +21,7 @@ pub struct CleanArgs { pub force: bool, /// Enable verbose output. #[arg(short, long)] - verbose: bool, + pub verbose: bool, } pub fn clean(args: &CleanArgs) -> Result<()> { diff --git a/src/subcommands/init.rs b/src/subcommands/init.rs index 08e6f32..528ca49 100644 --- a/src/subcommands/init.rs +++ b/src/subcommands/init.rs @@ -1,4 +1,6 @@ use std::{ + borrow::Cow, + fmt::Display, fs, path::{Path, PathBuf}, }; @@ -11,7 +13,7 @@ use git2::{ use path_absolutize::Absolutize; use crate::{ - config::ProjectConfig, + config::{PackConfig, ProjectConfig}, error::Error, terminal_output::{print_error, print_info, print_success}, }; @@ -28,17 +30,26 @@ pub struct InitArgs { #[arg(short, long)] pub description: Option, /// The pack format version. - #[arg(short, long, value_name = "FORMAT")] + #[arg(short, long, value_name = "FORMAT", visible_alias = "format")] pub pack_format: Option, + /// The path of the icon file. + #[arg(short, long = "icon", value_name = "PATH")] + pub icon_path: Option, /// Force initialization even if the directory is not empty. #[arg(short, long)] pub force: bool, - /// The version control system to initialize. - #[arg(long, default_value = "git")] - pub vcs: VersionControlSystem, + /// The version control system to initialize. [default: git] + #[arg(long)] + pub vcs: Option, /// Enable verbose output. #[arg(short, long)] - verbose: bool, + pub verbose: bool, + /// Enable batch mode. + /// + /// In batch mode, the command will not prompt the user for input and + /// will use the default values instead if possible or fail. + #[arg(long)] + pub batch: bool, } #[derive(Debug, Clone, Copy, Default, ValueEnum)] @@ -48,54 +59,258 @@ pub enum VersionControlSystem { None, } +impl Display for VersionControlSystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VersionControlSystem::Git => write!(f, "git"), + VersionControlSystem::None => write!(f, "none"), + } + } +} + pub fn init(args: &InitArgs) -> Result<()> { + if args.batch { + initialize_batch(args) + } else { + initialize_interactive(args) + } +} + +fn initialize_batch(args: &InitArgs) -> Result<()> { let verbose = args.verbose; + let force = args.force; let path = args.path.as_path(); let description = args.description.as_deref(); let pack_format = args.pack_format; - let force = args.force; + let vcs = args.vcs.unwrap_or(VersionControlSystem::Git); if !path.exists() { - print_error("The specified path does not exist."); - Err(Error::PathNotFoundError(path.to_path_buf()))? + if force { + fs::create_dir_all(path)?; + } else { + print_error("The specified path does not exist."); + Err(Error::PathNotFoundError(path.to_path_buf()))?; + } + } else if !path.is_dir() { + print_error("The specified path is not a directory."); + Err(Error::NotDirectoryError(path.to_path_buf()))?; + } else if !force && path.read_dir()?.next().is_some() { + print_error("The specified directory is not empty."); + Err(Error::NonEmptyDirectoryError(path.to_path_buf()))?; + } + + let name = args + .name + .as_deref() + .or_else(|| path.file_name().and_then(|os| os.to_str())); + + print_info("Initializing a new Shulkerscript project in batch mode..."); + + // Create the pack.toml file + create_pack_config(verbose, path, name, description, pack_format)?; + + // Create the pack.png file + create_pack_png(path, args.icon_path.as_deref(), verbose)?; + + // Create the src directory + let src_path = path.join("src"); + create_dir(&src_path, verbose)?; + + // Create the main.shu file + create_main_file( + path, + &name_to_namespace(name.unwrap_or(PackConfig::DEFAULT_NAME)), + verbose, + )?; + + // Initialize the version control system + initalize_vcs(path, vcs, verbose)?; + + print_success("Project initialized successfully."); + + Ok(()) +} + +fn initialize_interactive(args: &InitArgs) -> Result<()> { + const ABORT_MSG: &str = "Project initialization interrupted. Aborting..."; + + let verbose = args.verbose; + let force = args.force; + let path = args.path.as_path(); + let description = args.description.as_deref(); + let pack_format = args.pack_format; + + if !path.exists() { + if force { + fs::create_dir_all(path)?; + } else if let Ok(true) = + inquire::Confirm::new("The specified path does not exist. Do you want to create it?") + .with_default(true) + .prompt() + { + fs::create_dir_all(path)?; + } else { + print_info(ABORT_MSG); + return Ok(()); + } } else if !path.is_dir() { print_error("The specified path is not a directory."); Err(Error::NotDirectoryError(path.to_path_buf()))? } else if !force && path.read_dir()?.next().is_some() { - print_error("The specified directory is not empty."); - Err(Error::NonEmptyDirectoryError(path.to_path_buf()))? - } else { - let name = args - .name - .as_deref() - .or_else(|| path.file_name().and_then(|os| os.to_str())); - - print_info("Initializing a new Shulkerscript project..."); - - // Create the pack.toml file - create_pack_config(verbose, path, name, description, pack_format)?; - - // Create the pack.png file - create_pack_png(path, verbose)?; - - // Create the src directory - let src_path = path.join("src"); - create_dir(&src_path, verbose)?; - - // Create the main.shu file - create_main_file( - path, - &name_to_namespace(name.unwrap_or("shulkerscript-pack")), - verbose, - )?; - - // Initialize the version control system - initalize_vcs(path, args.vcs, verbose)?; - - print_success("Project initialized successfully."); - - Ok(()) + if let Ok(false) = + inquire::Confirm::new("The specified directory is not empty. Do you want to continue?") + .with_default(false) + .with_help_message("This may overwrite existing files in the directory.") + .prompt() + { + print_info(ABORT_MSG); + return Ok(()); + } } + + let mut interrupted = false; + + let name = args.name.as_deref().map(Cow::Borrowed).or_else(|| { + let default = path + .file_name() + .and_then(|os| os.to_str()) + .unwrap_or(PackConfig::DEFAULT_NAME); + + match inquire::Text::new("Enter the name of the project:") + .with_help_message("This will be the name of your datapack folder/zip file") + .with_default(default) + .prompt() + { + Ok(res) => Some(Cow::Owned(res)), + Err(_) => { + interrupted = true; + None + } + } + .or_else(|| { + path.file_name() + .and_then(|os| os.to_str().map(Cow::Borrowed)) + }) + }); + + if interrupted { + print_info(ABORT_MSG); + return Ok(()); + } + + let description = description.map(Cow::Borrowed).or_else(|| { + match inquire::Text::new("Enter the description of the project:") + .with_help_message("This will be the description of your datapack, visible in the datapack selection screen") + .with_default(PackConfig::DEFAULT_DESCRIPTION) + .prompt() { + Ok(res) => Some(Cow::Owned(res)), + Err(_) => { + interrupted = true; + None + } + } + }); + + if interrupted { + print_info(ABORT_MSG); + return Ok(()); + } + + let pack_format = pack_format.or_else(|| { + match inquire::Text::new("Enter the pack format:") + .with_help_message("This will determine the Minecraft version compatible with your pack, find more on the Minecraft wiki") + .with_default(PackConfig::DEFAULT_PACK_FORMAT.to_string().as_str()) + .prompt() { + Ok(res) => res.parse().ok(), + Err(_) => { + interrupted = true; + None + } + } + }); + + if interrupted { + print_info(ABORT_MSG); + return Ok(()); + } + + let vcs = args.vcs.unwrap_or_else(|| { + match inquire::Select::new( + "Select the version control system:", + vec![VersionControlSystem::Git, VersionControlSystem::None], + ) + .with_help_message("This will initialize a version control system") + .prompt() + { + Ok(res) => res, + Err(_) => { + interrupted = true; + VersionControlSystem::Git + } + } + }); + + if interrupted { + print_info(ABORT_MSG); + return Ok(()); + } + + let icon_path = args.icon_path.as_deref().map(Cow::Borrowed).or_else(|| { + let autocompleter = crate::util::PathAutocomplete::new(); + match inquire::Text::new("Enter the path of the icon file:") + .with_help_message( + "This will be the icon of your datapack, visible in the datapack selection screen [use \"-\" for default]", + ) + .with_autocomplete(autocompleter) + .with_default("-") + // .with_autocomplete() + .prompt() + { + Ok(res) if &res == "-" => None, + Ok(res) => Some(Cow::Owned(PathBuf::from(res))), + Err(_) => { + interrupted = true; + None + } + } + }); + + if interrupted { + print_info(ABORT_MSG); + return Ok(()); + } + + print_info("Initializing a new Shulkerscript project..."); + + // Create the pack.toml file + create_pack_config( + verbose, + path, + name.as_deref(), + description.as_deref(), + pack_format, + )?; + + // Create the pack.png file + create_pack_png(path, icon_path.as_deref(), verbose)?; + + // Create the src directory + let src_path = path.join("src"); + create_dir(&src_path, verbose)?; + + // Create the main.shu file + create_main_file( + path, + &name_to_namespace(&name.unwrap_or(Cow::Borrowed("shulkerscript-pack"))), + verbose, + )?; + + // Initialize the version control system + initalize_vcs(path, vcs, verbose)?; + + print_success("Project initialized successfully."); + + Ok(()) } fn create_pack_config( @@ -155,14 +370,29 @@ fn create_gitignore(path: &Path, verbose: bool) -> std::io::Result<()> { Ok(()) } -fn create_pack_png(path: &Path, verbose: bool) -> std::io::Result<()> { - let pack_png = path.join("pack.png"); - fs::write(&pack_png, include_bytes!("../../assets/default-icon.png"))?; - if verbose { - print_info(format!( - "Created pack.png file at {}.", - pack_png.absolutize()?.display() - )); +fn create_pack_png( + project_path: &Path, + icon_path: Option<&Path>, + verbose: bool, +) -> std::io::Result<()> { + let pack_png = project_path.join("pack.png"); + if let Some(icon_path) = icon_path { + fs::copy(icon_path, &pack_png)?; + if verbose { + print_info(format!( + "Copied pack.png file from {} to {}.", + icon_path.absolutize()?.display(), + pack_png.absolutize()?.display() + )); + } + } else { + fs::write(&pack_png, include_bytes!("../../assets/default-icon.png"))?; + if verbose { + print_info(format!( + "Created pack.png file at {}.", + pack_png.absolutize()?.display() + )); + } } Ok(()) } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..cb00906 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; + +use camino::Utf8PathBuf; + +use inquire::{autocompletion::Replacement, Autocomplete}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PathAutocomplete { + parent: String, + current: String, + outputs: Vec, + + cache: HashMap>, +} + +impl PathAutocomplete { + pub fn new() -> Self { + Self::default() + } + + fn split_input(input: &str) -> (&str, &str) { + let (parent, current) = if input.ends_with('/') { + (input.trim_end_matches('/'), "") + } else if let Some((parent, current)) = input.rsplit_once('/') { + if parent.is_empty() { + ("/", current) + } else { + (parent, current) + } + } else { + ("", input) + }; + let parent = if parent.is_empty() { "." } else { parent }; + + (parent, current) + } + + fn get_cached(&mut self, parent: &str) -> Result<&[String], &'static str> { + if !self.cache.contains_key(parent) { + tracing::trace!("Cache miss for \"{}\", reading dir", parent); + + let parent_path = Utf8PathBuf::from(parent); + if !parent_path.exists() || !parent_path.is_dir() { + return Err("Path does not exist"); + } + + let entries = parent_path + .read_dir_utf8() + .map_err(|_| "Could not read dir")? + .filter_map(|entry| { + entry.ok().map(|entry| { + entry.file_name().to_string() + if entry.path().is_dir() { "/" } else { "" } + }) + }) + .collect::>(); + + self.cache.insert(parent.to_string(), entries); + } + + Ok(self + .cache + .get(parent) + .expect("Previous caching did not work")) + } + + fn update_input(&mut self, input: &str) -> Result<(), inquire::CustomUserError> { + let (parent, current) = Self::split_input(input); + + if self.parent == parent && self.current == current { + Ok(()) + } else { + self.parent = parent.to_string(); + self.current = current.to_string(); + + self.outputs = self + .get_cached(parent)? + .iter() + .filter(|entry| entry.starts_with(current)) + .cloned() + .collect::>(); + + Ok(()) + } + } +} + +impl Autocomplete for PathAutocomplete { + fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { + self.update_input(input)?; + + Ok(self.outputs.clone()) + } + + fn get_completion( + &mut self, + input: &str, + highlighted_suggestion: Option, + ) -> Result { + let (parent, current) = Self::split_input(input); + + if let Some(highlighted) = highlighted_suggestion { + let completion = format!("{parent}/{highlighted}"); + self.update_input(&completion)?; + Ok(Replacement::Some(completion)) + } else if let Some(first) = self + .get_cached(parent)? + .iter() + .find(|entry| entry.starts_with(current)) + { + let completion = format!("{parent}/{first}"); + self.update_input(&completion)?; + Ok(Replacement::Some(completion)) + } else { + Ok(Replacement::None) + } + } +}