add interactive mode for init command

This commit is contained in:
Moritz Hölting 2024-06-17 16:37:33 +02:00
parent e8ac54f231
commit 7da26c9590
7 changed files with 416 additions and 65 deletions

View File

@ -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"

View File

@ -18,8 +18,10 @@ Where [PATH] is the path of the folder to initialize in [default: `.`]
Options:
- `--name <NAME>` The name of the project
- `--description <DESCRIPTION>` The description of the project
- `--pack-format <FORMAT>` The pack format version
- `--pack-format <FORMAT>` The pack format version
- `--icon <PATH>` 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

View File

@ -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.

View File

@ -3,6 +3,7 @@ mod config;
mod error;
mod subcommands;
mod terminal_output;
mod util;
use std::process::ExitCode;

View File

@ -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<()> {

View File

@ -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<String>,
/// The pack format version.
#[arg(short, long, value_name = "FORMAT")]
#[arg(short, long, value_name = "FORMAT", visible_alias = "format")]
pub pack_format: Option<u8>,
/// The path of the icon file.
#[arg(short, long = "icon", value_name = "PATH")]
pub icon_path: Option<PathBuf>,
/// 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<VersionControlSystem>,
/// 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(())
}

117
src/util.rs Normal file
View File

@ -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<String>,
cache: HashMap<String, Vec<String>>,
}
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::<Vec<_>>();
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::<Vec<_>>();
Ok(())
}
}
}
impl Autocomplete for PathAutocomplete {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
self.update_input(input)?;
Ok(self.outputs.clone())
}
fn get_completion(
&mut self,
input: &str,
highlighted_suggestion: Option<String>,
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
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)
}
}
}