From 4d49d4d0bdb8d6034ba8126718448511e911365d Mon Sep 17 00:00:00 2001 From: Jack T Date: Sun, 26 May 2024 09:48:35 -0500 Subject: [PATCH] Initial commit --- src/args.rs | 10 - src/cli.rs | 22 ++ src/commands/mod.rs | 1 + src/{ => commands/sync}/codegen/flat.rs | 4 +- src/{ => commands/sync}/codegen/mod.rs | 3 +- src/{ => commands/sync}/codegen/nested/ast.rs | 0 src/{ => commands/sync}/codegen/nested/mod.rs | 12 +- src/{ => commands/sync}/config.rs | 2 +- src/commands/sync/mod.rs | 221 +++++++++++++++++ src/{ => commands/sync}/state.rs | 13 +- src/{ => commands/sync}/upload.rs | 0 src/{ => commands/sync}/util/alpha_bleed.rs | 0 src/{ => commands/sync}/util/mod.rs | 0 src/{ => commands/sync}/util/svg.rs | 0 src/main.rs | 226 +----------------- 15 files changed, 268 insertions(+), 246 deletions(-) delete mode 100644 src/args.rs create mode 100644 src/cli.rs create mode 100644 src/commands/mod.rs rename src/{ => commands/sync}/codegen/flat.rs (90%) rename src/{ => commands/sync}/codegen/mod.rs (98%) rename src/{ => commands/sync}/codegen/nested/ast.rs (100%) rename src/{ => commands/sync}/codegen/nested/mod.rs (99%) rename src/{ => commands/sync}/config.rs (97%) create mode 100644 src/commands/sync/mod.rs rename src/{ => commands/sync}/state.rs (92%) rename src/{ => commands/sync}/upload.rs (100%) rename src/{ => commands/sync}/util/alpha_bleed.rs (100%) rename src/{ => commands/sync}/util/mod.rs (100%) rename src/{ => commands/sync}/util/svg.rs (100%) diff --git a/src/args.rs b/src/args.rs deleted file mode 100644 index df7aa05..0000000 --- a/src/args.rs +++ /dev/null @@ -1,10 +0,0 @@ -use clap::Parser; - -#[derive(Parser, Debug)] -#[command(version, about = "Sync assets to Roblox.")] -pub struct Args { - /// Your Open Cloud API key. - /// Can also be set with the ASPHALT_API_KEY environment variable. - #[arg(short, long)] - pub api_key: Option, -} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..696f41e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,22 @@ +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser)] +#[command(version, about = "Upload and reference Roblox assets in code.")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Sync assets to Roblox. + Sync(SyncArgs), +} + +#[derive(Args)] +pub struct SyncArgs { + /// Your Open Cloud API key. + /// Can also be set with the ASPHALT_API_KEY environment variable. + #[arg(short, long)] + pub api_key: Option, +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..d086d5b --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod sync; diff --git a/src/codegen/flat.rs b/src/commands/sync/codegen/flat.rs similarity index 90% rename from src/codegen/flat.rs rename to src/commands/sync/codegen/flat.rs index 8906e31..f52bc70 100644 --- a/src/codegen/flat.rs +++ b/src/commands/sync/codegen/flat.rs @@ -24,7 +24,7 @@ pub fn generate_lua( .entries .iter() .map(|(file_path, file_entry)| { - let file_stem = asset_path(&file_path, &strip_dir, strip_extension)?; + let file_stem = asset_path(file_path, strip_dir, strip_extension)?; Ok(format!( "\t[\"{}\"] = \"rbxassetid://{}\"", file_stem, file_entry.asset_id @@ -46,7 +46,7 @@ pub fn generate_ts( .entries .keys() .map(|file_path| { - let file_stem = asset_path(&file_path, &strip_dir, strip_extension)?; + let file_stem = asset_path(file_path, strip_dir, strip_extension)?; Ok(format!("\t\"{}\": string", file_stem)) }) .collect::, anyhow::Error>>()? diff --git a/src/codegen/mod.rs b/src/commands/sync/codegen/mod.rs similarity index 98% rename from src/codegen/mod.rs rename to src/commands/sync/codegen/mod.rs index 1aa2a3c..c70a5cc 100644 --- a/src/codegen/mod.rs +++ b/src/commands/sync/codegen/mod.rs @@ -1,5 +1,4 @@ -use crate::{config::StyleType, LockFile}; - +use crate::{commands::sync::config::StyleType, LockFile}; mod flat; mod nested; diff --git a/src/codegen/nested/ast.rs b/src/commands/sync/codegen/nested/ast.rs similarity index 100% rename from src/codegen/nested/ast.rs rename to src/commands/sync/codegen/nested/ast.rs diff --git a/src/codegen/nested/mod.rs b/src/commands/sync/codegen/nested/mod.rs similarity index 99% rename from src/codegen/nested/mod.rs rename to src/commands/sync/codegen/nested/mod.rs index ee7906a..4ff39b9 100644 --- a/src/codegen/nested/mod.rs +++ b/src/commands/sync/codegen/nested/mod.rs @@ -1,15 +1,13 @@ -mod ast; - +use self::types::NestedTable; +use crate::LockFile; use anyhow::{bail, Context}; +use ast::{AstTarget, Expression, ReturnStatement}; use std::collections::BTreeMap; +use std::fmt::Write; use std::path::PathBuf; use std::{path::Component as PathComponent, path::Path}; -use crate::LockFile; -use ast::{AstTarget, Expression, ReturnStatement}; -use std::fmt::Write; - -use self::types::NestedTable; +mod ast; pub(crate) mod types { use std::collections::BTreeMap; diff --git a/src/config.rs b/src/commands/sync/config.rs similarity index 97% rename from src/config.rs rename to src/commands/sync/config.rs index 96e52c4..9977e50 100644 --- a/src/config.rs +++ b/src/commands/sync/config.rs @@ -38,7 +38,7 @@ pub struct CodegenConfig { } #[derive(Debug, Deserialize, Serialize)] -pub struct Config { +pub struct SyncConfig { pub asset_dir: String, pub write_dir: String, pub creator: Creator, diff --git a/src/commands/sync/mod.rs b/src/commands/sync/mod.rs new file mode 100644 index 0000000..4b190cb --- /dev/null +++ b/src/commands/sync/mod.rs @@ -0,0 +1,221 @@ +use self::state::SyncState; +use crate::{cli::SyncArgs, FileEntry}; +use anyhow::{anyhow, Context}; +use blake3::Hasher; +use codegen::{generate_lua, generate_ts}; +use config::SyncConfig; +use console::style; +use image::{DynamicImage, ImageFormat}; +use rbxcloud::rbx::v1::assets::AssetType; +use std::{collections::VecDeque, io::Cursor, path::Path}; +use tokio::fs::{read, read_dir, read_to_string, write, DirEntry}; +use upload::upload_asset; +use util::{alpha_bleed::alpha_bleed, svg::svg_to_png}; + +mod codegen; +pub mod config; +mod state; +mod upload; +mod util; + +fn fix_path(path: &str) -> String { + path.replace('\\', "/") +} + +async fn check_file(entry: &DirEntry, state: &SyncState) -> anyhow::Result> { + let path = entry.path(); + let path_str = path.to_str().context("Failed to convert path to string")?; + let fixed_path = fix_path(path_str); + + let mut bytes = read(&path) + .await + .with_context(|| format!("Failed to read {}", fixed_path))?; + + let mut extension = match path.extension().and_then(|s| s.to_str()) { + Some(extension) => extension, + None => return Ok(None), + }; + + if extension == "svg" { + bytes = svg_to_png(&bytes, &state.font_db) + .await + .with_context(|| format!("Failed to convert SVG to PNG: {}", fixed_path))?; + extension = "png"; + } + + let asset_type = match AssetType::try_from_extension(extension) { + Ok(asset_type) => asset_type, + Err(e) => { + eprintln!( + "Skipping {} because it has an unsupported extension: {}", + style(fixed_path).yellow(), + e + ); + return Ok(None); + } + }; + + #[cfg(feature = "alpha_bleed")] + match asset_type { + AssetType::DecalJpeg | AssetType::DecalBmp | AssetType::DecalPng => { + let mut image: DynamicImage = image::load_from_memory(&bytes)?; + alpha_bleed(&mut image); + + let format = ImageFormat::from_extension(extension).ok_or(anyhow!( + "Failed to get image format from extension: {}", + extension + ))?; + + let mut new_bytes: Cursor> = Cursor::new(Vec::new()); + image.write_to(&mut new_bytes, format)?; + + bytes = new_bytes.into_inner(); + } + _ => {} + } + + let mut hasher = Hasher::new(); + hasher.update(&bytes); + let hash = hasher.finalize().to_string(); + + let existing = state.existing_lockfile.entries.get(fixed_path.as_str()); + + if let Some(existing_value) = existing { + if existing_value.hash == hash { + return Ok(Some(FileEntry { + hash, + asset_id: existing_value.asset_id, + })); + } + } + + let file_name = path + .file_name() + .with_context(|| format!("Failed to get file name of {}", fixed_path))? + .to_str() + .with_context(|| format!("Failed to convert file name to string: {}", fixed_path))?; + + let asset_id = upload_asset( + bytes, + file_name, + asset_type, + state.api_key.clone(), + state.creator.clone(), + ) + .await + .with_context(|| format!("Failed to upload {}", fixed_path))?; + + eprintln!("Uploaded {}", style(fixed_path).green()); + + Ok(Some(FileEntry { hash, asset_id })) +} + +pub async fn sync(args: SyncArgs) -> anyhow::Result<()> { + let config: SyncConfig = { + let file_contents = read_to_string("asphalt.toml") + .await + .context("Failed to read asphalt.toml")?; + toml::from_str(&file_contents).context("Failed to parse config") + }?; + + let mut state = SyncState::new(args, config) + .await + .context("Failed to create state")?; + + eprintln!("{}", style("Syncing...").dim()); + + let mut remaining_items = VecDeque::new(); + remaining_items.push_back(state.asset_dir.clone()); + + while let Some(path) = remaining_items.pop_front() { + let mut dir_entries = read_dir(path.clone()).await.with_context(|| { + format!( + "Failed to read directory: {}", + path.to_str().unwrap_or("???") + ) + })?; + + while let Some(entry) = dir_entries + .next_entry() + .await + .with_context(|| format!("Failed to read directory entry: {:?}", path))? + { + let entry_path = entry.path(); + if entry_path.is_dir() { + remaining_items.push_back(entry_path); + } else { + let result = match check_file(&entry, &state).await { + Ok(Some(result)) => result, + Ok(None) => continue, + Err(e) => { + eprintln!("{} {:?}", style("Error:").red(), e); + continue; + } + }; + + let path_str = entry_path.to_str().with_context(|| { + format!("Failed to convert path to string: {:?}", entry_path) + })?; + let fixed_path = fix_path(path_str); + + state.new_lockfile.entries.insert(fixed_path, result); + } + } + } + + write( + "asphalt.lock.toml", + toml::to_string(&state.new_lockfile).context("Failed to serialize lockfile")?, + ) + .await + .context("Failed to write lockfile")?; + + let asset_dir_str = state + .asset_dir + .to_str() + .context("Failed to convert asset directory to string")?; + + state + .new_lockfile + .entries + .extend(state.existing.into_iter().map(|(path, asset)| { + ( + path, + FileEntry { + hash: "".to_string(), + asset_id: asset.id, + }, + ) + })); + + let lua_filename = format!("{}.{}", state.output_name, state.lua_extension); + let lua_output = generate_lua( + &state.new_lockfile, + asset_dir_str, + &state.style, + state.strip_extension, + ); + + write(Path::new(&state.write_dir).join(lua_filename), lua_output?) + .await + .context("Failed to write output Lua file")?; + + if state.typescript { + let ts_filename = format!("{}.d.ts", state.output_name); + let ts_output = generate_ts( + &state.new_lockfile, + asset_dir_str, + state.output_name.as_str(), + &state.style, + state.strip_extension, + ); + + write(Path::new(&state.write_dir).join(ts_filename), ts_output?) + .await + .context("Failed to write output TypeScript file")?; + } + + eprintln!("{}", style("Synced!").dim()); + + Ok(()) +} diff --git a/src/state.rs b/src/commands/sync/state.rs similarity index 92% rename from src/state.rs rename to src/commands/sync/state.rs index 1e55705..68ee648 100644 --- a/src/state.rs +++ b/src/commands/sync/state.rs @@ -1,6 +1,6 @@ use crate::{ - args::Args, - config::{Config, CreatorType, ExistingAsset, StyleType}, + cli::SyncArgs, + commands::sync::config::{CreatorType, ExistingAsset, StyleType}, LockFile, }; use anyhow::Context; @@ -9,6 +9,8 @@ use resvg::usvg::fontdb::Database; use std::{collections::HashMap, env, path::PathBuf}; use tokio::fs::{create_dir_all, read_to_string}; +use super::config::SyncConfig; + fn add_trailing_slash(path: &str) -> String { if !path.ends_with('/') { return format!("{}/", path); @@ -25,7 +27,8 @@ fn get_api_key(arg_key: Option) -> anyhow::Result { None => env_key.context("No API key provided"), } } -pub struct State { + +pub struct SyncState { pub asset_dir: PathBuf, pub write_dir: PathBuf, @@ -46,8 +49,8 @@ pub struct State { pub existing: HashMap, } -impl State { - pub async fn new(args: Args, config: Config) -> anyhow::Result { +impl SyncState { + pub async fn new(args: SyncArgs, config: SyncConfig) -> anyhow::Result { let api_key = get_api_key(args.api_key)?; let creator: AssetCreator = match config.creator.creator_type { diff --git a/src/upload.rs b/src/commands/sync/upload.rs similarity index 100% rename from src/upload.rs rename to src/commands/sync/upload.rs diff --git a/src/util/alpha_bleed.rs b/src/commands/sync/util/alpha_bleed.rs similarity index 100% rename from src/util/alpha_bleed.rs rename to src/commands/sync/util/alpha_bleed.rs diff --git a/src/util/mod.rs b/src/commands/sync/util/mod.rs similarity index 100% rename from src/util/mod.rs rename to src/commands/sync/util/mod.rs diff --git a/src/util/svg.rs b/src/commands/sync/util/svg.rs similarity index 100% rename from src/util/svg.rs rename to src/commands/sync/util/svg.rs diff --git a/src/main.rs b/src/main.rs index 0380fe2..f46ac34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,232 +1,20 @@ -use crate::{ - config::Config, - util::{alpha_bleed::alpha_bleed, svg}, -}; -use anyhow::{anyhow, Context}; -use args::Args; -use blake3::Hasher; use clap::Parser; -use codegen::{generate_lua, generate_ts}; -use console::style; +use cli::{Cli, Commands}; +use commands::sync::sync; use dotenv::dotenv; -use image::{DynamicImage, ImageFormat}; pub use lockfile::{FileEntry, LockFile}; -use rbxcloud::rbx::v1::assets::AssetType; -use state::State; -use std::{collections::VecDeque, io::Cursor, path::Path}; -use tokio::fs::{read, read_dir, read_to_string, write, DirEntry}; -use upload::upload_asset; -pub mod args; -mod codegen; -pub mod config; +pub mod cli; +mod commands; pub mod lockfile; -pub mod state; -mod upload; -mod util; - -fn fix_path(path: &str) -> String { - path.replace('\\', "/") -} - -async fn check_file(entry: &DirEntry, state: &State) -> anyhow::Result> { - let path = entry.path(); - let path_str = path.to_str().context("Failed to convert path to string")?; - let fixed_path = fix_path(path_str); - - let mut bytes = read(&path) - .await - .with_context(|| format!("Failed to read {}", fixed_path))?; - - let mut extension = match path.extension().and_then(|s| s.to_str()) { - Some(extension) => extension, - None => return Ok(None), - }; - - if extension == "svg" { - bytes = svg::svg_to_png(&bytes, &state.font_db) - .await - .with_context(|| format!("Failed to convert SVG to PNG: {}", fixed_path))?; - extension = "png"; - } - - let asset_type = match AssetType::try_from_extension(extension) { - Ok(asset_type) => asset_type, - Err(e) => { - eprintln!( - "Skipping {} because it has an unsupported extension: {}", - style(fixed_path).yellow(), - e - ); - return Ok(None); - } - }; - - #[cfg(feature = "alpha_bleed")] - match asset_type { - AssetType::DecalJpeg | AssetType::DecalBmp | AssetType::DecalPng => { - let mut image: DynamicImage = image::load_from_memory(&bytes)?; - alpha_bleed(&mut image); - - let format = ImageFormat::from_extension(extension).ok_or(anyhow!( - "Failed to get image format from extension: {}", - extension - ))?; - - let mut new_bytes: Cursor> = Cursor::new(Vec::new()); - image.write_to(&mut new_bytes, format)?; - - bytes = new_bytes.into_inner(); - } - _ => {} - } - - let mut hasher = Hasher::new(); - hasher.update(&bytes); - let hash = hasher.finalize().to_string(); - - let existing = state.existing_lockfile.entries.get(fixed_path.as_str()); - - if let Some(existing_value) = existing { - if existing_value.hash == hash { - return Ok(Some(FileEntry { - hash, - asset_id: existing_value.asset_id, - })); - } - } - - let file_name = path - .file_name() - .with_context(|| format!("Failed to get file name of {}", fixed_path))? - .to_str() - .with_context(|| format!("Failed to convert file name to string: {}", fixed_path))?; - - let asset_id = upload_asset( - bytes, - file_name, - asset_type, - state.api_key.clone(), - state.creator.clone(), - ) - .await - .with_context(|| format!("Failed to upload {}", fixed_path))?; - - eprintln!("Uploaded {}", style(fixed_path).green()); - - Ok(Some(FileEntry { hash, asset_id })) -} #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv().ok(); - let args = Args::parse(); - let config: Config = { - let file_contents = read_to_string("asphalt.toml") - .await - .context("Failed to read asphalt.toml")?; - toml::from_str(&file_contents).context("Failed to parse config") - }?; - - let mut state = State::new(args, config) - .await - .context("Failed to create state")?; - - eprintln!("{}", style("Syncing...").dim()); + let args = Cli::parse(); - let mut remaining_items = VecDeque::new(); - remaining_items.push_back(state.asset_dir.clone()); - - while let Some(path) = remaining_items.pop_front() { - let mut dir_entries = read_dir(path.clone()).await.with_context(|| { - format!( - "Failed to read directory: {}", - path.to_str().unwrap_or("???") - ) - })?; - - while let Some(entry) = dir_entries - .next_entry() - .await - .with_context(|| format!("Failed to read directory entry: {:?}", path))? - { - let entry_path = entry.path(); - if entry_path.is_dir() { - remaining_items.push_back(entry_path); - } else { - let result = match check_file(&entry, &state).await { - Ok(Some(result)) => result, - Ok(None) => continue, - Err(e) => { - eprintln!("{} {:?}", style("Error:").red(), e); - continue; - } - }; - - let path_str = entry_path.to_str().with_context(|| { - format!("Failed to convert path to string: {:?}", entry_path) - })?; - let fixed_path = fix_path(path_str); - - state.new_lockfile.entries.insert(fixed_path, result); - } - } + match args.command { + Commands::Sync(sync_args) => sync(sync_args).await, } - - write( - "asphalt.lock.toml", - toml::to_string(&state.new_lockfile).context("Failed to serialize lockfile")?, - ) - .await - .context("Failed to write lockfile")?; - - let asset_dir_str = state - .asset_dir - .to_str() - .context("Failed to convert asset directory to string")?; - - state - .new_lockfile - .entries - .extend(state.existing.into_iter().map(|(path, asset)| { - ( - path, - FileEntry { - hash: "".to_string(), - asset_id: asset.id, - }, - ) - })); - - let lua_filename = format!("{}.{}", state.output_name, state.lua_extension); - let lua_output = generate_lua( - &state.new_lockfile, - asset_dir_str, - &state.style, - state.strip_extension, - ); - - write(Path::new(&state.write_dir).join(lua_filename), lua_output?) - .await - .context("Failed to write output Lua file")?; - - if state.typescript { - let ts_filename = format!("{}.d.ts", state.output_name); - let ts_output = generate_ts( - &state.new_lockfile, - asset_dir_str, - state.output_name.as_str(), - &state.style, - state.strip_extension, - ); - - write(Path::new(&state.write_dir).join(ts_filename), ts_output?) - .await - .context("Failed to write output TypeScript file")?; - } - - eprintln!("{}", style("Synced!").dim()); - - Ok(()) }