shulkerscript_cli/subcommands/
build.rs

1use anyhow::Result;
2use path_absolutize::Absolutize;
3use shulkerscript::{
4    base::{FsProvider, PrintHandler},
5    shulkerbox::{
6        util::compile::CompileOptions,
7        virtual_fs::{VFile, VFolder},
8    },
9};
10
11use crate::{
12    config::ProjectConfig,
13    error::Error,
14    terminal_output::{print_error, print_info, print_success, print_warning},
15    util,
16};
17use std::{
18    borrow::Cow,
19    fs,
20    path::{Path, PathBuf},
21};
22
23#[derive(Debug, clap::Args, Clone)]
24pub struct BuildArgs {
25    /// The path of the project to build.
26    #[arg(default_value = ".")]
27    pub path: PathBuf,
28    /// Path of output directory
29    ///
30    /// The path of the directory to place the compiled datapack.
31    #[arg(short, long, env = "DATAPACK_DIR")]
32    pub output: Option<PathBuf>,
33    /// Path of the assets folder
34    ///
35    /// The path of a folder which files and subfolders will be copied to the root of the datapack.
36    /// Overrides the `assets` field in the pack.toml file.
37    #[arg(short, long)]
38    pub assets: Option<PathBuf>,
39    /// Package the project to a zip file.
40    #[arg(short, long)]
41    pub zip: bool,
42    /// Skip validating the project for pack format compatibility.
43    #[arg(long)]
44    pub no_validate: bool,
45    /// Check if the project can be built without actually building it.
46    #[arg(long, conflicts_with_all = ["output", "zip"])]
47    pub check: bool,
48}
49
50pub fn build(args: &BuildArgs) -> Result<()> {
51    if args.zip && !cfg!(feature = "zip") {
52        print_error("The zip feature is not enabled. Please install with the `zip` feature enabled to use the `--zip` option.");
53        return Err(Error::FeatureNotEnabledError("zip".to_string()).into());
54    }
55
56    let path = util::get_project_path(&args.path).unwrap_or(args.path.clone());
57    let dist_path = args
58        .output
59        .as_ref()
60        .map(Cow::Borrowed)
61        .unwrap_or_else(|| Cow::Owned(path.join("dist")));
62
63    let and_package_msg = if args.zip { " and packaging" } else { "" };
64
65    let mut path_display = format!("{}", path.display());
66    if path_display.is_empty() {
67        path_display.push('.');
68    }
69
70    print_info(format!(
71        "Building{and_package_msg} project at {path_display}"
72    ));
73
74    let (project_config, toml_path) = get_pack_config(&path)?;
75
76    let script_paths = get_script_paths(
77        &toml_path
78            .parent()
79            .ok_or(Error::InvalidPackPathError(path.to_path_buf()))?
80            .join("src"),
81    )?;
82
83    let datapack = shulkerscript::transpile(
84        &PrintHandler::new(),
85        &FsProvider::default(),
86        project_config.pack.pack_format,
87        &script_paths,
88    )?;
89
90    if !args.no_validate && !datapack.validate() {
91        print_warning(format!(
92            "The datapack is not compatible with the specified pack format: {}",
93            project_config.pack.pack_format
94        ));
95        return Err(Error::IncompatiblePackVersionError.into());
96    }
97
98    let mut compiled = datapack.compile(&CompileOptions::default());
99
100    let icon_path = toml_path.parent().unwrap().join("pack.png");
101
102    if icon_path.is_file() {
103        if let Ok(icon_data) = fs::read(icon_path) {
104            compiled.add_file("pack.png", VFile::Binary(icon_data));
105        }
106    }
107
108    let assets_path = args.assets.clone().or(project_config
109        .compiler
110        .as_ref()
111        .and_then(|c| c.assets.as_ref().map(|p| path.join(p))));
112
113    let output = if let Some(assets_path) = assets_path {
114        let assets = VFolder::try_from(assets_path.as_path());
115        if assets.is_err() {
116            print_error(format!(
117                "The specified assets path does not exist: {}",
118                assets_path.display()
119            ));
120        }
121        let mut assets = assets?;
122        let replaced = assets.merge(compiled);
123
124        for replaced in replaced {
125            print_warning(format!(
126                "Template file {} was replaced by a file in the compiled datapack",
127                replaced
128            ));
129        }
130
131        assets
132    } else {
133        compiled
134    };
135
136    let dist_extension = if args.zip { ".zip" } else { "" };
137
138    let dist_path = dist_path.join(project_config.pack.name + dist_extension);
139
140    if args.check {
141        print_success("Project is valid and can be built.");
142    } else {
143        #[cfg(feature = "zip")]
144        if args.zip {
145            output.zip_with_comment(
146                &dist_path,
147                format!(
148                    "{} - v{}",
149                    &project_config.pack.description, &project_config.pack.version
150                ),
151            )?;
152        } else {
153            output.place(&dist_path)?;
154        }
155
156        #[cfg(not(feature = "zip"))]
157        output.place(&dist_path)?;
158
159        print_success(format!(
160            "Finished building{and_package_msg} project to {}",
161            dist_path.absolutize_from(path)?.display()
162        ));
163    }
164
165    Ok(())
166}
167
168/// Recursively get all script paths in a directory.
169pub(super) fn get_script_paths(path: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
170    _get_script_paths(path, "")
171}
172
173fn _get_script_paths(path: &Path, prefix: &str) -> std::io::Result<Vec<(String, PathBuf)>> {
174    if path.exists() && path.is_dir() {
175        let contents = path.read_dir()?;
176
177        let mut paths = Vec::new();
178
179        for entry in contents {
180            let path = entry?.path();
181            if path.is_dir() {
182                let prefix = path
183                    .absolutize()?
184                    .file_name()
185                    .unwrap()
186                    .to_str()
187                    .expect("Invalid folder name")
188                    .to_string()
189                    + "/";
190                paths.extend(_get_script_paths(&path, &prefix)?);
191            } else if path.extension().unwrap_or_default() == "shu" {
192                paths.push((
193                    prefix.to_string()
194                        + path
195                            .file_stem()
196                            .expect("Shulkerscript files are not allowed to have empty names")
197                            .to_str()
198                            .expect("Invalid characters in filename"),
199                    path,
200                ));
201            }
202        }
203
204        Ok(paths)
205    } else {
206        Ok(Vec::new())
207    }
208}
209
210/// Get the pack config and config path from a project path.
211///
212/// # Errors
213/// - If the specified path does not exist.
214/// - If the specified directory does not contain a pack.toml file.
215pub(super) fn get_pack_config(path: &Path) -> Result<(ProjectConfig, PathBuf)> {
216    let path = path.absolutize()?;
217    let toml_path = if !path.exists() {
218        print_error("The specified path does not exist.");
219        return Err(Error::PathNotFoundError(path.to_path_buf()))?;
220    } else if path.is_dir() {
221        let toml_path = path.join("pack.toml");
222        if !toml_path.exists() {
223            print_error("The specified directory does not contain a pack.toml file.");
224            Err(Error::InvalidPackPathError(path.to_path_buf()))?;
225        }
226        toml_path
227    } else if path.is_file()
228        && path
229            .file_name()
230            .ok_or(Error::InvalidPackPathError(path.to_path_buf()))?
231            == "pack.toml"
232    {
233        path.to_path_buf()
234    } else {
235        print_error("The specified path is neither a directory nor a pack.toml file.");
236        return Err(Error::InvalidPackPathError(path.to_path_buf()))?;
237    };
238
239    let toml_content = fs::read_to_string(&toml_path)?;
240    let project_config = toml::from_str::<ProjectConfig>(&toml_content)?;
241
242    Ok((project_config, toml_path))
243}