shulkerscript_cli/subcommands/
migrate.rs

1use 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    /// The path of the project to migrate.
21    #[arg(default_value = ".")]
22    pub path: PathBuf,
23    /// The path of the folder to create the Shulkerscript project.
24    pub target: PathBuf,
25    /// Force migration even if some features will be lost.
26    #[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    // check if there are any directories in namespaces other than `functions`, `function` and `tags`
120    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    // stringify the toml fragments and add them to the pack.toml file
188    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    // migrate all subfolders of namespace
206    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            // migrate functions
217            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            // migrate tags
227            for tag_type in subfolder.path().read_dir()? {
228                handle_tag_type_dir(root, &namespace_name, &tag_type?.path())?;
229            }
230        } else {
231            // copy all other files to the asset folder
232            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    // indent lines and prefix comments with `///` and commands with `/`
255    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    // generate the full content of the function file
276    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    // loop through all tag files in the tag type directory
304    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        // generate "of <type>" if the tag type is not "function"
329        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        // indent, quote and join the values
338        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}