365 lines
12 KiB
Rust
365 lines
12 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::ops::Deref;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use clap::{value_parser, Arg, ArgMatches, Command};
|
|
use color_eyre::eyre::{self, Context, Result};
|
|
use color_eyre::{Help, Report};
|
|
use dtmt_shared::ModConfig;
|
|
use futures::future::try_join_all;
|
|
use futures::StreamExt;
|
|
use sdk::filetype::package::Package;
|
|
use sdk::murmur::IdString64;
|
|
use sdk::{Bundle, BundleFile};
|
|
use tokio::fs::{self, File};
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::sync::Mutex;
|
|
|
|
const PROJECT_CONFIG_NAME: &str = "dtmt.cfg";
|
|
|
|
type FileIndexMap = HashMap<String, HashSet<String>>;
|
|
|
|
pub(crate) fn command_definition() -> Command {
|
|
Command::new("build")
|
|
.about("Build a project")
|
|
.arg(
|
|
Arg::new("directory")
|
|
.required(false)
|
|
.value_parser(value_parser!(PathBuf))
|
|
.help(
|
|
"The path to the project to build. \
|
|
If omitted, dtmt will search from the current working directory upward.",
|
|
),
|
|
)
|
|
.arg(
|
|
Arg::new("out")
|
|
.long("out")
|
|
.short('o')
|
|
.default_value("out")
|
|
.value_parser(value_parser!(PathBuf))
|
|
.help("The directory to write output files to."),
|
|
)
|
|
.arg(
|
|
Arg::new("deploy")
|
|
.long("deploy")
|
|
.short('d')
|
|
.value_parser(value_parser!(PathBuf))
|
|
.help(
|
|
"If the path to the game (without the trailing '/bundle') is specified, \
|
|
deploy the newly built bundles. \
|
|
This will not adjust the bundle database or package files, so if files are \
|
|
added or removed, you will have to import into DTMM and re-deploy there.",
|
|
),
|
|
)
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
async fn find_project_config(dir: Option<PathBuf>) -> Result<ModConfig> {
|
|
let (path, mut file) = if let Some(path) = dir {
|
|
let file = File::open(&path.join(PROJECT_CONFIG_NAME))
|
|
.await
|
|
.wrap_err_with(|| format!("failed to open file: {}", path.display()))
|
|
.with_suggestion(|| {
|
|
format!(
|
|
"Make sure the file at '{}' exists and is readable",
|
|
path.display()
|
|
)
|
|
})?;
|
|
(path, file)
|
|
} else {
|
|
let mut dir = std::env::current_dir()?;
|
|
loop {
|
|
let path = dir.join(PROJECT_CONFIG_NAME);
|
|
match File::open(&path).await {
|
|
Ok(file) => break (dir, file),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
if let Some(parent) = dir.parent() {
|
|
// TODO: Re-write with recursion to avoid allocating the `PathBuf`.
|
|
dir = parent.to_path_buf();
|
|
} else {
|
|
eyre::bail!("Could not find project root");
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let err = Report::new(err)
|
|
.wrap_err(format!("failed to open file: {}", path.display()));
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut buf = String::new();
|
|
file.read_to_string(&mut buf)
|
|
.await
|
|
.wrap_err("invalid UTF-8")?;
|
|
|
|
let mut cfg: ModConfig =
|
|
serde_sjson::from_str(&buf).wrap_err("failed to deserialize mod config")?;
|
|
cfg.dir = path;
|
|
Ok(cfg)
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
async fn compile_package_files<P>(pkg: &Package, root: P) -> Result<Vec<BundleFile>>
|
|
where
|
|
P: AsRef<Path> + std::fmt::Debug,
|
|
{
|
|
let root = Arc::new(root.as_ref());
|
|
|
|
let tasks = pkg
|
|
.iter()
|
|
.flat_map(|(file_type, paths)| {
|
|
paths.iter().map(|path| {
|
|
(
|
|
*file_type,
|
|
path,
|
|
// Cloning the `Arc` here solves the issue that in the next `.map`, I need to
|
|
// `move` the closure parameters, but can't `move` `root` before it was cloned.
|
|
root.clone(),
|
|
)
|
|
})
|
|
})
|
|
.map(|(file_type, path, root)| async move {
|
|
let sjson = fs::read_to_string(&path).await?;
|
|
|
|
let mut path = path.clone();
|
|
path.set_extension("");
|
|
|
|
BundleFile::from_sjson(
|
|
path.to_string_lossy().to_string(),
|
|
file_type,
|
|
sjson,
|
|
root.as_ref(),
|
|
)
|
|
.await
|
|
});
|
|
|
|
let results = futures::stream::iter(tasks)
|
|
.buffer_unordered(10)
|
|
.collect::<Vec<Result<BundleFile>>>()
|
|
.await;
|
|
|
|
results.into_iter().collect()
|
|
}
|
|
|
|
#[tracing::instrument(skip_all, fields(files = files.len()))]
|
|
fn compile_bundle(name: String, files: Vec<BundleFile>) -> Result<Bundle> {
|
|
let mut bundle = Bundle::new(name);
|
|
|
|
for file in files {
|
|
bundle.add_file(file);
|
|
}
|
|
|
|
Ok(bundle)
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
async fn build_package<P1, P2>(package: P1, root: P2) -> Result<Bundle>
|
|
where
|
|
P1: AsRef<Path> + std::fmt::Debug,
|
|
P2: AsRef<Path> + std::fmt::Debug,
|
|
{
|
|
let root = root.as_ref();
|
|
let package = package.as_ref();
|
|
|
|
let mut path = root.join(package);
|
|
path.set_extension("package");
|
|
let sjson = fs::read_to_string(&path)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to read file {}", path.display()))?;
|
|
|
|
let pkg_name = package.to_string_lossy().to_string();
|
|
let pkg = Package::from_sjson(sjson, pkg_name.clone(), root)
|
|
.await
|
|
.wrap_err_with(|| format!("invalid package file {}", &pkg_name))?;
|
|
|
|
compile_package_files(&pkg, root)
|
|
.await
|
|
.wrap_err("failed to compile package")
|
|
.and_then(|files| compile_bundle(pkg_name, files))
|
|
.wrap_err("failed to build bundle")
|
|
}
|
|
|
|
fn normalize_file_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
|
|
let path = path.as_ref();
|
|
|
|
if path.is_absolute() || path.has_root() {
|
|
let err = eyre::eyre!("path is absolute: {}", path.display());
|
|
return Err(err).with_suggestion(|| "Specify a relative file path.".to_string());
|
|
}
|
|
|
|
let path = path_clean::clean(path);
|
|
|
|
if path.starts_with("..") {
|
|
eyre::bail!("path starts with a parent component: {}", path.display());
|
|
}
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub(crate) async fn read_project_config(dir: Option<PathBuf>) -> Result<ModConfig> {
|
|
let mut cfg = find_project_config(dir).await?;
|
|
|
|
cfg.resources.init = normalize_file_path(cfg.resources.init)
|
|
.wrap_err("invalid config field 'resources.init'")
|
|
.with_suggestion(|| {
|
|
"Specify a file path relative to and child path of the \
|
|
directory where 'dtmt.cfg' is."
|
|
.to_string()
|
|
})
|
|
.with_suggestion(|| {
|
|
"Use 'dtmt new' in a separate directory to generate \
|
|
a valid mod template."
|
|
.to_string()
|
|
})?;
|
|
|
|
if let Some(path) = cfg.resources.data {
|
|
let path = normalize_file_path(path)
|
|
.wrap_err("invalid config field 'resources.data'")
|
|
.with_suggestion(|| {
|
|
"Specify a file path relative to and child path of the \
|
|
directory where 'dtmt.cfg' is."
|
|
.to_string()
|
|
})
|
|
.with_suggestion(|| {
|
|
"Use 'dtmt new' in a separate directory to generate \
|
|
a valid mod template."
|
|
.to_string()
|
|
})?;
|
|
cfg.resources.data = Some(path);
|
|
}
|
|
|
|
if let Some(path) = cfg.resources.localization {
|
|
let path = normalize_file_path(path)
|
|
.wrap_err("invalid config field 'resources.localization'")
|
|
.with_suggestion(|| {
|
|
"Specify a file path relative to and child path of the \
|
|
directory where 'dtmt.cfg' is."
|
|
.to_string()
|
|
})
|
|
.with_suggestion(|| {
|
|
"Use 'dtmt new' in a separate directory to generate \
|
|
a valid mod template."
|
|
.to_string()
|
|
})?;
|
|
cfg.resources.localization = Some(path);
|
|
}
|
|
|
|
Ok(cfg)
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
|
|
let cfg = read_project_config(matches.get_one::<PathBuf>("directory").cloned()).await?;
|
|
|
|
let game_dir = matches
|
|
.get_one::<PathBuf>("deploy")
|
|
.map(|p| p.join("bundle"));
|
|
|
|
let out_path = matches
|
|
.get_one::<PathBuf>("out")
|
|
.expect("parameter should have default value");
|
|
|
|
tracing::debug!(?cfg, ?game_dir, ?out_path);
|
|
|
|
let game_dir = Arc::new(game_dir);
|
|
let cfg = Arc::new(cfg);
|
|
|
|
fs::create_dir_all(out_path)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to create output directory '{}'", out_path.display()))?;
|
|
|
|
let file_map = Arc::new(Mutex::new(FileIndexMap::new()));
|
|
|
|
let tasks = cfg
|
|
.packages
|
|
.iter()
|
|
// The closure below would capture the `Arc`s before they could be cloned,
|
|
// so instead we need to clone them in a non-move block and inject them
|
|
// via parameters.
|
|
.map(|path| (path, cfg.clone(), file_map.clone(), game_dir.clone()))
|
|
.map(|(path, cfg, file_map, game_dir)| async move {
|
|
if path.extension().is_some() {
|
|
eyre::bail!(
|
|
"Package name must be specified without file extension: {}",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
let bundle = build_package(path, &cfg.dir).await.wrap_err_with(|| {
|
|
format!(
|
|
"failed to build package {} in {}",
|
|
path.display(),
|
|
cfg.dir.display()
|
|
)
|
|
})?;
|
|
|
|
let bundle_name = match bundle.name() {
|
|
IdString64::Hash(_) => {
|
|
eyre::bail!("bundle name must be known as string. got hash")
|
|
}
|
|
IdString64::String(s) => s.clone(),
|
|
};
|
|
|
|
{
|
|
let mut file_map = file_map.lock().await;
|
|
let map_entry = file_map.entry(bundle_name).or_default();
|
|
|
|
for file in bundle.files() {
|
|
map_entry.insert(file.name(false, None));
|
|
}
|
|
}
|
|
|
|
let name = bundle.name().to_murmur64().to_string().to_ascii_lowercase();
|
|
let path = out_path.join(&name);
|
|
let data = bundle.to_binary()?;
|
|
|
|
tracing::trace!(
|
|
"Writing bundle {} to '{}'",
|
|
bundle.name().display(),
|
|
path.display()
|
|
);
|
|
fs::write(&path, &data)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to write bundle to '{}'", path.display()))?;
|
|
|
|
if let Some(game_dir) = game_dir.as_ref() {
|
|
let path = game_dir.join(&name);
|
|
|
|
tracing::trace!(
|
|
"Deploying bundle {} to '{}'",
|
|
bundle.name().display(),
|
|
path.display()
|
|
);
|
|
fs::write(&path, &data)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to write bundle to '{}'", path.display()))?;
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
|
|
try_join_all(tasks)
|
|
.await
|
|
.wrap_err("failed to build mod bundles")?;
|
|
|
|
{
|
|
let file_map = file_map.lock().await;
|
|
let data = serde_sjson::to_string(file_map.deref())?;
|
|
let path = out_path.join("files.sjson");
|
|
fs::write(&path, data)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to write file index to '{}'", path.display()))?;
|
|
}
|
|
|
|
tracing::info!("Compiled bundles written to '{}'", out_path.display());
|
|
|
|
if let Some(game_dir) = game_dir.as_ref() {
|
|
tracing::info!("Deployed bundles to '{}'", game_dir.display());
|
|
}
|
|
|
|
Ok(())
|
|
}
|