shulkerscript_cli/subcommands/
migrate.rs1use anyhow::Result;
2use path_absolutize::Absolutize as _;
3use shulkerscript::shulkerbox::virtual_fs::{VFile, VFolder};
4use std::{
5 borrow::Cow,
6 fs::{self, File},
7 io::BufReader,
8 path::{Path, PathBuf},
9};
10use walkdir::WalkDir;
11
12use crate::{
13 terminal_output::{print_error, print_info, print_success},
14 util::Relativize as _,
15};
16
17#[derive(Debug, clap::Args, Clone)]
18#[command(allow_missing_positional = true)]
19pub struct MigrateArgs {
20 #[arg(default_value = ".")]
22 pub path: PathBuf,
23 pub target: PathBuf,
25 #[arg(short, long)]
27 pub force: bool,
28}
29
30pub fn migrate(args: &MigrateArgs) -> Result<()> {
31 let base_path = args.path.as_path();
32 let base_path = if base_path.is_absolute() {
33 Cow::Borrowed(base_path)
34 } else {
35 base_path.absolutize().unwrap_or(Cow::Borrowed(base_path))
36 }
37 .ancestors()
38 .find(|p| p.join("pack.mcmeta").exists())
39 .map(|p| p.relativize().unwrap_or_else(|| p.to_path_buf()));
40
41 if let Some(base_path) = base_path {
42 print_info(format!(
43 "Migrating from {:?} to {:?}",
44 base_path, args.target
45 ));
46
47 let mcmeta_path = base_path.join("pack.mcmeta");
48 let mcmeta: serde_json::Value =
49 serde_json::from_reader(BufReader::new(fs::File::open(&mcmeta_path)?))?;
50
51 if !args.force && !is_mcmeta_compatible(&mcmeta) {
52 print_error("Your datapack uses features in the pack.mcmeta file that are not yet supported by Shulkerscript.");
53 print_error(
54 r#""features", "filter", "overlays" and "language" will get lost if you continue."#,
55 );
56 print_error("Use the force flag to continue anyway.");
57
58 return Err(anyhow::anyhow!("Incompatible mcmeta."));
59 }
60
61 let mcmeta = serde_json::from_value::<McMeta>(mcmeta)?;
62
63 let mut root = VFolder::new();
64 root.add_file("pack.toml", generate_pack_toml(&base_path, &mcmeta)?);
65
66 let data_path = base_path.join("data");
67 if data_path.exists() && data_path.is_dir() {
68 for namespace in data_path.read_dir()? {
69 let namespace = namespace?;
70 if namespace.file_type()?.is_dir() {
71 handle_namespace(&mut root, &namespace.path())?;
72 }
73 }
74 } else {
75 print_error("Could not find a data folder.");
76 }
77
78 root.place(&args.target)?;
79
80 let logo_path = base_path.join("pack.png");
81 if logo_path.exists() {
82 fs::copy(logo_path, args.target.join("pack.png"))?;
83 }
84
85 print_success("Migration successful.");
86 Ok(())
87 } else {
88 let msg = format!(
89 "Could not find a valid datapack to migrate at {}.",
90 args.path.display()
91 );
92 print_error(&msg);
93 Err(anyhow::anyhow!("{}", &msg))
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
98struct McMeta {
99 pack: McMetaPack,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
103struct McMetaPack {
104 description: String,
105 pack_format: u8,
106}
107
108fn is_mcmeta_compatible(mcmeta: &serde_json::Value) -> bool {
109 mcmeta.as_object().map_or(false, |mcmeta| {
110 mcmeta.len() == 1
111 && mcmeta.contains_key("pack")
112 && mcmeta["pack"]
113 .as_object()
114 .is_some_and(|pack| !pack.contains_key("supported_formats"))
115 })
116}
117
118fn generate_pack_toml(base_path: &Path, mcmeta: &McMeta) -> Result<VFile> {
119 let mut err = false;
121 let requires_assets_dir = base_path.join("data").read_dir()?.any(|entry_i| {
122 if let Ok(entry_i) = entry_i {
123 if let Ok(metadata_i) = entry_i.metadata() {
124 metadata_i.is_dir()
125 && entry_i
126 .path()
127 .read_dir()
128 .map(|mut dir| {
129 dir.any(|entry_ii| {
130 if let Ok(entry_ii) = entry_ii {
131 ["functions", "function", "tags"]
132 .contains(&entry_ii.file_name().to_string_lossy().as_ref())
133 } else {
134 err = true;
135 true
136 }
137 })
138 })
139 .map_err(|e| {
140 err = true;
141 e
142 })
143 .unwrap_or_default()
144 } else {
145 err = true;
146 true
147 }
148 } else {
149 err = true;
150 true
151 }
152 });
153
154 if err {
155 print_error("Error reading data directory");
156 return Err(anyhow::anyhow!("Error reading data directory"));
157 }
158
159 let assets_dir_fragment = requires_assets_dir.then(|| {
160 toml::toml! {
161 [compiler]
162 assets = "./assets"
163 }
164 });
165
166 let name = base_path
167 .absolutize()?
168 .file_name()
169 .expect("No file name")
170 .to_string_lossy()
171 .into_owned();
172 let description = mcmeta.pack.description.as_str();
173 let pack_format = mcmeta.pack.pack_format;
174
175 let main_fragment = toml::toml! {
176 [pack]
177 name = name
178 description = description
179 format = pack_format
180 version = "0.1.0"
181 };
182
183 let assets_dir_fragment_text = assets_dir_fragment
184 .map(|fragment| toml::to_string_pretty(&fragment))
185 .transpose()?;
186
187 toml::to_string_pretty(&main_fragment)
189 .map(|mut text| {
190 if let Some(assets_dir_fragment_text) = assets_dir_fragment_text {
191 text.push('\n');
192 text.push_str(&assets_dir_fragment_text);
193 }
194 VFile::Text(text)
195 })
196 .map_err(|e| e.into())
197}
198
199fn handle_namespace(root: &mut VFolder, namespace: &Path) -> Result<()> {
200 let namespace_name = namespace
201 .file_name()
202 .expect("path cannot end with ..")
203 .to_string_lossy();
204
205 for subfolder in namespace.read_dir()? {
207 let subfolder = subfolder?;
208 if !subfolder.file_type()?.is_dir() {
209 continue;
210 }
211
212 let filename = subfolder.file_name();
213 let filename = filename.to_string_lossy();
214
215 if ["function", "functions"].contains(&filename.as_ref()) {
216 for entry in WalkDir::new(subfolder.path()).min_depth(1) {
218 let entry = entry?;
219 if entry.file_type().is_file()
220 && entry.path().extension().unwrap_or_default() == "mcfunction"
221 {
222 handle_function(root, namespace, &namespace_name, entry.path())?;
223 }
224 }
225 } else if filename.as_ref() == "tags" {
226 for tag_type in subfolder.path().read_dir()? {
228 handle_tag_type_dir(root, &namespace_name, &tag_type?.path())?;
229 }
230 } else {
231 let vfolder = VFolder::try_from(subfolder.path().as_path())?;
233 root.add_existing_folder(&format!("assets/data/{namespace_name}/{filename}"), vfolder);
234 }
235 }
236
237 Ok(())
238}
239
240fn handle_function(
241 root: &mut VFolder,
242 namespace: &Path,
243 namespace_name: &str,
244 function: &Path,
245) -> Result<()> {
246 let function_path = pathdiff::diff_paths(function, namespace.join("function"))
247 .expect("function path is always a subpath of namespace/function")
248 .to_string_lossy()
249 .replace('\\', "/");
250 let function_path = function_path
251 .trim_start_matches("./")
252 .trim_end_matches(".mcfunction");
253
254 let content = fs::read_to_string(function)?
256 .lines()
257 .map(|l| {
258 if l.trim_start().starts_with('#') {
259 format!(" {}", l.replacen('#', "///", 1))
260 } else if l.is_empty() {
261 String::new()
262 } else {
263 format!(" /{}", l)
264 }
265 })
266 .collect::<Vec<_>>()
267 .join("\n");
268
269 let function_name = function_path
270 .split('/')
271 .last()
272 .expect("split always returns at least one element")
273 .replace(|c: char| !c.is_ascii_alphanumeric(), "_");
274
275 let full_content = indoc::formatdoc!(
277 r#"// This file was automatically migrated by Shulkerscript CLI v{version} from file "{function}"
278 namespace "{namespace_name}";
279
280 #[deobfuscate = "{function_path}"]
281 fn {function_name}() {{
282 {content}
283 }}
284 "#,
285 version = env!("CARGO_PKG_VERSION"),
286 function = function.display()
287 );
288
289 root.add_file(
290 &format!("src/functions/{namespace_name}/{function_path}.shu"),
291 VFile::Text(full_content),
292 );
293
294 Ok(())
295}
296
297fn handle_tag_type_dir(root: &mut VFolder, namespace: &str, tag_type_dir: &Path) -> Result<()> {
298 let tag_type = tag_type_dir
299 .file_name()
300 .expect("cannot end with ..")
301 .to_string_lossy();
302
303 for entry in WalkDir::new(tag_type_dir).min_depth(1) {
305 let entry = entry?;
306 if entry.file_type().is_file() && entry.path().extension().unwrap_or_default() == "json" {
307 handle_tag(root, namespace, tag_type_dir, &tag_type, entry.path())?;
308 }
309 }
310
311 Ok(())
312}
313
314fn handle_tag(
315 root: &mut VFolder,
316 namespace: &str,
317 tag_type_dir: &Path,
318 tag_type: &str,
319 tag: &Path,
320) -> Result<()> {
321 let tag_path = pathdiff::diff_paths(tag, tag_type_dir)
322 .expect("tag path is always a subpath of tag_type_dir")
323 .to_string_lossy()
324 .replace('\\', "/");
325 let tag_path = tag_path.trim_start_matches("./").trim_end_matches(".json");
326
327 if let Ok(content) = serde_json::from_reader::<_, Tag>(BufReader::new(File::open(tag)?)) {
328 let of_type = if tag_type == "function" {
330 String::new()
331 } else {
332 format!(r#" of "{tag_type}""#)
333 };
334
335 let replace = if content.replace { " replace" } else { "" };
336
337 let values = content
339 .values
340 .iter()
341 .map(|t| format!(r#" "{t}""#))
342 .collect::<Vec<_>>()
343 .join(",\n");
344
345 let generated = indoc::formatdoc!(
346 r#"// This file was automatically migrated by Shulkerscript CLI v{version} from file "{tag}"
347 namespace "{namespace}";
348
349 tag "{tag_path}"{of_type}{replace} [
350 {values}
351 ]
352 "#,
353 version = env!("CARGO_PKG_VERSION"),
354 tag = tag.display(),
355 );
356
357 root.add_file(
358 &format!("src/tags/{namespace}/{tag_type}/{tag_path}.shu"),
359 VFile::Text(generated),
360 );
361
362 Ok(())
363 } else {
364 print_error(format!(
365 "Could not read tag file at {}. Required attribute of entries is not yet supported",
366 tag.display()
367 ));
368 Err(anyhow::anyhow!(
369 "Could not read tag file at {}",
370 tag.display()
371 ))
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
376struct Tag {
377 #[serde(default)]
378 replace: bool,
379 values: Vec<String>,
380}