use anyhow::Result; use path_absolutize::Absolutize as _; use shulkerbox::virtual_fs::{VFile, VFolder}; use std::{ borrow::Cow, fs::{self, File}, io::BufReader, path::{Path, PathBuf}, }; use walkdir::WalkDir; use crate::{ terminal_output::{print_error, print_info, print_success}, util::Relativize as _, }; #[derive(Debug, clap::Args, Clone)] #[command(allow_missing_positional = true)] pub struct MigrateArgs { /// The path of the project to migrate. #[arg(default_value = ".")] pub path: PathBuf, /// The path of the folder to create the ShulkerScript project. pub target: PathBuf, /// Force migration even if some features will be lost. #[arg(short, long)] pub force: bool, } pub fn migrate(args: &MigrateArgs) -> Result<()> { let base_path = args.path.as_path(); let base_path = if base_path.is_absolute() { Cow::Borrowed(base_path) } else { base_path.absolutize().unwrap_or(Cow::Borrowed(base_path)) } .ancestors() .find(|p| p.join("pack.mcmeta").exists()) .map(|p| p.relativize().unwrap_or_else(|| p.to_path_buf())); if let Some(base_path) = base_path { print_info(format!( "Migrating from {:?} to {:?}", base_path, args.target )); let mcmeta_path = base_path.join("pack.mcmeta"); let mcmeta: serde_json::Value = serde_json::from_reader(BufReader::new(fs::File::open(&mcmeta_path)?))?; if !args.force && !is_mcmeta_compatible(&mcmeta) { print_error("Your datapack uses features in the pack.mcmeta file that are not yet supported by ShulkerScript."); print_error( r#""features", "filter", "overlays" and "language" will get lost if you continue."#, ); print_error("Use the force flag to continue anyway."); return Err(anyhow::anyhow!("Incompatible mcmeta.")); } let mcmeta = serde_json::from_value::(mcmeta)?; let mut root = VFolder::new(); root.add_file("pack.toml", generate_pack_toml(&base_path, &mcmeta)?); let data_path = base_path.join("data"); if data_path.exists() && data_path.is_dir() { for namespace in data_path.read_dir()? { let namespace = namespace?; if namespace.file_type()?.is_dir() { handle_namespace(&mut root, &namespace.path())?; } } } else { print_error("Could not find a data folder."); } root.place(&args.target)?; print_success("Migration successful."); Ok(()) } else { let msg = format!( "Could not find a valid datapack to migrate at {}.", args.path.display() ); print_error(&msg); Err(anyhow::anyhow!("{}", &msg)) } } #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] struct McMeta { pack: McMetaPack, } #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] struct McMetaPack { description: String, pack_format: u8, } fn is_mcmeta_compatible(mcmeta: &serde_json::Value) -> bool { mcmeta.as_object().map_or(false, |mcmeta| { mcmeta.len() == 1 && mcmeta.contains_key("pack") && mcmeta["pack"] .as_object() .is_some_and(|pack| !pack.contains_key("supported_formats")) }) } fn generate_pack_toml(base_path: &Path, mcmeta: &McMeta) -> Result { // check if there are any directories in namespaces other than `functions`, `function` and `tags` let mut err = false; let requires_assets_dir = base_path.join("data").read_dir()?.any(|entry_i| { if let Ok(entry_i) = entry_i { if let Ok(metadata_i) = entry_i.metadata() { metadata_i.is_dir() && entry_i .path() .read_dir() .map(|mut dir| { dir.any(|entry_ii| { if let Ok(entry_ii) = entry_ii { ["functions", "function", "tags"] .contains(&entry_ii.file_name().to_string_lossy().as_ref()) } else { err = true; true } }) }) .map_err(|e| { err = true; e }) .unwrap_or_default() } else { err = true; true } } else { err = true; true } }); if err { print_error("Error reading data directory"); return Err(anyhow::anyhow!("Error reading data directory")); } let assets_dir_fragment = requires_assets_dir.then(|| { toml::toml! { [compiler] assets = "./assets" } }); let name = base_path .absolutize()? .file_name() .expect("No file name") .to_string_lossy() .into_owned(); let description = mcmeta.pack.description.as_str(); let pack_format = mcmeta.pack.pack_format; let main_fragment = toml::toml! { [pack] name = name description = description format = pack_format version = "0.1.0" }; let assets_dir_fragment_text = assets_dir_fragment .map(|fragment| toml::to_string_pretty(&fragment)) .transpose()?; // stringify the toml fragments and add them to the pack.toml file toml::to_string_pretty(&main_fragment) .map(|mut text| { if let Some(assets_dir_fragment_text) = assets_dir_fragment_text { text.push('\n'); text.push_str(&assets_dir_fragment_text); } VFile::Text(text) }) .map_err(|e| e.into()) } fn handle_namespace(root: &mut VFolder, namespace: &Path) -> Result<()> { let namespace_name = namespace .file_name() .expect("path cannot end with ..") .to_string_lossy(); // migrate all subfolders of namespace for subfolder in namespace.read_dir()? { let subfolder = subfolder?; if !subfolder.file_type()?.is_dir() { continue; } let filename = subfolder.file_name(); let filename = filename.to_string_lossy(); if ["function", "functions"].contains(&filename.as_ref()) { // migrate functions for entry in WalkDir::new(subfolder.path()).min_depth(1) { let entry = entry?; if entry.file_type().is_file() && entry.path().extension().unwrap_or_default() == "mcfunction" { handle_function(root, namespace, &namespace_name, entry.path())?; } } } else if filename.as_ref() == "tags" { // migrate tags for tag_type in subfolder.path().read_dir()? { handle_tag_type_dir(root, &namespace_name, &tag_type?.path())?; } } else { // copy all other files to the asset folder let vfolder = VFolder::try_from(subfolder.path().as_path())?; root.add_existing_folder(&format!("assets/data/{namespace_name}/{filename}"), vfolder); } } Ok(()) } fn handle_function( root: &mut VFolder, namespace: &Path, namespace_name: &str, function: &Path, ) -> Result<()> { let function_path = pathdiff::diff_paths(function, namespace.join("function")) .expect("function path is always a subpath of namespace/function") .to_string_lossy() .replace('\\', "/"); let function_path = function_path .trim_start_matches("./") .trim_end_matches(".mcfunction"); // indent lines and prefix comments with `///` and commands with `/` let content = fs::read_to_string(function)? .lines() .map(|l| { if l.trim_start().starts_with('#') { format!(" {}", l.replacen('#', "///", 1)) } else if l.is_empty() { String::new() } else { format!(" /{}", l) } }) .collect::>() .join("\n"); let function_name = function_path .split('/') .last() .expect("split always returns at least one element") .replace(|c: char| !c.is_ascii_alphanumeric(), "_"); // generate the full content of the function file let full_content = indoc::formatdoc!( r#"// This file was automatically migrated by ShulkerScript CLI v{version} from file "{function}" namespace "{namespace_name}"; #[deobfuscate = "{function_path}"] fn {function_name}() {{ {content} }} "#, version = env!("CARGO_PKG_VERSION"), function = function.display() ); root.add_file( &format!("src/functions/{namespace_name}/{function_path}.shu"), VFile::Text(full_content), ); Ok(()) } fn handle_tag_type_dir(root: &mut VFolder, namespace: &str, tag_type_dir: &Path) -> Result<()> { let tag_type = tag_type_dir .file_name() .expect("cannot end with ..") .to_string_lossy(); // loop through all tag files in the tag type directory for entry in WalkDir::new(tag_type_dir).min_depth(1) { let entry = entry?; if entry.file_type().is_file() && entry.path().extension().unwrap_or_default() == "json" { handle_tag(root, namespace, tag_type_dir, &tag_type, entry.path())?; } } Ok(()) } fn handle_tag( root: &mut VFolder, namespace: &str, tag_type_dir: &Path, tag_type: &str, tag: &Path, ) -> Result<()> { let tag_path = pathdiff::diff_paths(tag, tag_type_dir) .expect("tag path is always a subpath of tag_type_dir") .to_string_lossy() .replace('\\', "/"); let tag_path = tag_path.trim_start_matches("./").trim_end_matches(".json"); if let Ok(content) = serde_json::from_reader::<_, Tag>(BufReader::new(File::open(tag)?)) { // generate "of " if the tag type is not "function" let of_type = if tag_type == "function" { String::new() } else { format!(r#" of "{tag_type}""#) }; let replace = if content.replace { " replace" } else { "" }; // indent, quote and join the values let values = content .values .iter() .map(|t| format!(r#" "{t}""#)) .collect::>() .join(",\n"); let generated = indoc::formatdoc!( r#"// This file was automatically migrated by ShulkerScript CLI v{version} from file "{tag}" namespace "{namespace}"; tag "{tag_path}"{of_type}{replace} [ {values} ] "#, version = env!("CARGO_PKG_VERSION"), tag = tag.display(), ); root.add_file( &format!("src/tags/{namespace}/{tag_type}/{tag_path}.shu"), VFile::Text(generated), ); Ok(()) } else { print_error(format!( "Could not read tag file at {}. Required attribute of entries is not yet supported", tag.display() )); Err(anyhow::anyhow!( "Could not read tag file at {}", tag.display() )) } } #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] struct Tag { #[serde(default)] replace: bool, values: Vec, }