From 5021ec2e2cab271b4cce7d2deb804cb74a049ebd Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Sun, 26 Oct 2025 18:18:53 -0400 Subject: [PATCH 01/10] Fatal error handling --- Cargo.lock | 1 + Cargo.toml | 1 + src/sync/backend/cloud.rs | 21 ++++-- src/sync/backend/debug.rs | 9 ++- src/sync/backend/mod.rs | 21 ++++-- src/sync/backend/studio.rs | 18 ++++-- src/sync/mod.rs | 8 +-- src/sync/perform.rs | 17 +++-- src/web_api.rs | 128 +++++++++++++++++++++++-------------- 9 files changed, 146 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0dc8f3..38503e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "thiserror 2.0.17", "tokio", "toml 0.9.8", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index 2c1627b..7923060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ roblox_install = "1.0.0" schemars = "1.0.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" +thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["full"] } toml = "0.9.8" walkdir = "2.5.0" diff --git a/src/sync/backend/cloud.rs b/src/sync/backend/cloud.rs index 88fcfe1..4806913 100644 --- a/src/sync/backend/cloud.rs +++ b/src/sync/backend/cloud.rs @@ -1,5 +1,10 @@ -use super::{BackendSyncResult, SyncBackend}; -use crate::{asset::Asset, sync::SyncState}; +use super::{AssetRef, SyncBackend}; +use crate::{ + asset::Asset, + sync::{SyncState, backend::SyncError}, + web_api::UploadError, +}; +use anyhow::anyhow; use std::sync::Arc; use tokio::time; @@ -18,14 +23,16 @@ impl SyncBackend for CloudBackend { state: Arc, _input_name: String, asset: &Asset, - ) -> anyhow::Result> { + ) -> Result, SyncError> { if cfg!(feature = "mock_cloud") { time::sleep(time::Duration::from_secs(1)).await; - return Ok(Some(BackendSyncResult::Cloud(1337))); + return Ok(Some(AssetRef::Cloud(1337))); } - let asset_id = state.client.upload(asset).await?; - - Ok(Some(BackendSyncResult::Cloud(asset_id))) + match state.client.upload(asset).await { + Ok(id) => Ok(Some(AssetRef::Cloud(id))), + Err(UploadError::Fatal { message, .. }) => Err(SyncError::Fatal(anyhow!(message))), + Err(UploadError::Other(e)) => Err(SyncError::Fatal(e)), + } } } diff --git a/src/sync/backend/debug.rs b/src/sync/backend/debug.rs index fca4027..34ae4fe 100644 --- a/src/sync/backend/debug.rs +++ b/src/sync/backend/debug.rs @@ -1,5 +1,8 @@ -use super::{BackendSyncResult, SyncBackend}; -use crate::{asset::Asset, sync::SyncState}; +use super::{AssetRef, SyncBackend}; +use crate::{ + asset::Asset, + sync::{SyncState, backend::SyncError}, +}; use anyhow::Context; use fs_err::tokio as fs; use log::info; @@ -37,7 +40,7 @@ impl SyncBackend for DebugBackend { _state: Arc, _input_name: String, asset: &Asset, - ) -> anyhow::Result> { + ) -> Result, SyncError> { let target_path = asset.path.to_logical_path(&self.sync_path); if let Some(parent) = target_path.parent() { diff --git a/src/sync/backend/mod.rs b/src/sync/backend/mod.rs index 07df72b..2b54f79 100644 --- a/src/sync/backend/mod.rs +++ b/src/sync/backend/mod.rs @@ -7,11 +7,6 @@ pub mod cloud; pub mod debug; pub mod studio; -pub enum BackendSyncResult { - Cloud(u64), - Studio(String), -} - pub trait SyncBackend { async fn new() -> anyhow::Result where @@ -22,5 +17,19 @@ pub trait SyncBackend { state: Arc, input_name: String, asset: &Asset, - ) -> anyhow::Result>; + ) -> Result, SyncError>; +} + +pub enum AssetRef { + Cloud(u64), + Studio(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + #[error("Fatal error: {0}")] + Fatal(anyhow::Error), + + #[error(transparent)] + Other(#[from] anyhow::Error), } diff --git a/src/sync/backend/studio.rs b/src/sync/backend/studio.rs index b7edc92..9e6dca6 100644 --- a/src/sync/backend/studio.rs +++ b/src/sync/backend/studio.rs @@ -1,7 +1,7 @@ -use super::{BackendSyncResult, SyncBackend}; +use super::{AssetRef, SyncBackend}; use crate::{ asset::{Asset, AssetType}, - sync::SyncState, + sync::{SyncState, backend::SyncError}, }; use anyhow::{Context, bail}; use fs_err::tokio as fs; @@ -54,10 +54,10 @@ impl SyncBackend for StudioBackend { state: Arc, input_name: String, asset: &Asset, - ) -> anyhow::Result> { + ) -> Result, SyncError> { if matches!(asset.ty, AssetType::Model(_) | AssetType::Animation) { return match state.existing_lockfile.get(&input_name, &asset.hash) { - Some(entry) => Ok(Some(BackendSyncResult::Studio(format!( + Some(entry) => Ok(Some(AssetRef::Studio(format!( "rbxassetid://{}", entry.asset_id )))), @@ -74,12 +74,16 @@ impl SyncBackend for StudioBackend { let target_path = rel_target_path.to_logical_path(&self.sync_path); if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).await?; + fs::create_dir_all(parent) + .await + .map_err(anyhow::Error::from)?; } - fs::write(&target_path, &asset.data).await?; + fs::write(&target_path, &asset.data) + .await + .map_err(anyhow::Error::from)?; - Ok(Some(BackendSyncResult::Studio(format!( + Ok(Some(AssetRef::Studio(format!( "rbxasset://{}/{}", self.identifier, rel_target_path )))) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 5c5ae09..dbf20c7 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -6,7 +6,7 @@ use crate::{ web_api::WebApiClient, }; use anyhow::{Context, Result, bail}; -use backend::BackendSyncResult; +use backend::AssetRef; use indicatif::MultiProgress; use log::{info, warn}; use relative_path::RelativePathBuf; @@ -232,7 +232,7 @@ pub struct SyncResult { hash: String, path: RelativePathBuf, input_name: String, - backend: BackendSyncResult, + asset_ref: AssetRef, } async fn handle_sync_results( @@ -241,7 +241,7 @@ async fn handle_sync_results( lockfile_tx: Sender, ) -> anyhow::Result<()> { while let Some(result) = rx.recv().await { - if let BackendSyncResult::Cloud(asset_id) = result.backend { + if let AssetRef::Cloud(asset_id) = result.asset_ref { lockfile_tx .send(LockfileInsertion { input_name: result.input_name.clone(), @@ -258,7 +258,7 @@ async fn handle_sync_results( asset_id: format!("rbxassetid://{asset_id}"), }) .await?; - } else if let BackendSyncResult::Studio(asset_id) = result.backend { + } else if let AssetRef::Studio(asset_id) = result.asset_ref { codegen_tx .send(CodegenInsertion { input_name: result.input_name, diff --git a/src/sync/perform.rs b/src/sync/perform.rs index d072bc1..ae43bbf 100644 --- a/src/sync/perform.rs +++ b/src/sync/perform.rs @@ -2,7 +2,13 @@ use super::{ SyncState, backend::{SyncBackend, cloud::CloudBackend, debug::DebugBackend, studio::StudioBackend}, }; -use crate::{asset::Asset, cli::SyncTarget, progress_bar::ProgressBar, sync::SyncResult}; +use crate::{ + asset::Asset, + cli::SyncTarget, + progress_bar::ProgressBar, + sync::{SyncResult, backend::SyncError}, +}; +use anyhow::bail; use log::warn; use std::sync::Arc; @@ -38,21 +44,24 @@ pub async fn perform( }; match res { - Ok(Some(result)) => { + Ok(Some(asset_ref)) => { state .result_tx .send(SyncResult { input_name: input_name.clone(), hash: asset.hash.clone(), path: asset.path.clone(), - backend: result, + asset_ref, }) .await?; } + Ok(None) => {} + Err(SyncError::Fatal(err)) => { + bail!("Failed to sync asset {file_name}: {err:?}"); + } Err(err) => { warn!("Failed to sync asset {file_name}: {err:?}"); } - _ => {} }; pb.inc(1); diff --git a/src/web_api.rs b/src/web_api.rs index b50f0c8..12b1cb6 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -1,9 +1,9 @@ use crate::{ asset::{Asset, AssetType}, auth::Auth, - config::{Creator, CreatorType}, + config, }; -use anyhow::{Context, bail}; +use anyhow::{Context, anyhow}; use log::{debug, warn}; use reqwest::{ RequestBuilder, Response, StatusCode, @@ -18,15 +18,28 @@ const OPERATION_URL: &str = "https://apis.roblox.com/assets/v1/operations"; const ASSET_DESCRIPTION: &str = "Uploaded by Asphalt"; const MAX_DISPLAY_NAME_LENGTH: usize = 50; +#[derive(Debug, thiserror::Error)] +pub enum UploadError { + #[error("Fatal error: (status: {status}, message: {message}, body: {body})")] + Fatal { + status: StatusCode, + message: String, + body: String, + }, + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + pub struct WebApiClient { inner: reqwest::Client, auth: Auth, - creator: Creator, + creator: config::Creator, expected_price: Option, } impl WebApiClient { - pub fn new(auth: Auth, creator: Creator, expected_price: Option) -> Self { + pub fn new(auth: Auth, creator: config::Creator, expected_price: Option) -> Self { WebApiClient { inner: reqwest::Client::new(), auth, @@ -35,7 +48,7 @@ impl WebApiClient { } } - pub async fn upload(&self, asset: &Asset) -> anyhow::Result { + pub async fn upload(&self, asset: &Asset) -> Result { let api_key = self .auth .api_key @@ -45,10 +58,10 @@ impl WebApiClient { let file_name = asset.path.file_name().unwrap(); let display_name = trim_display_name(file_name); - let req = WebAssetRequest { + let req = Request { display_name, asset_type: asset.ty.clone(), - creation_context: WebAssetRequestCreationContext { + creation_context: CreationContext { creator: self.creator.clone().into(), expected_price: self.expected_price, }, @@ -56,7 +69,7 @@ impl WebApiClient { }; let len = asset.data.len() as u64; - let req_json = serde_json::to_string(&req)?; + let req_json = serde_json::to_string(&req).map_err(anyhow::Error::from)?; let mime = req.asset_type.file_type().to_owned(); let name = file_name.to_owned(); @@ -81,23 +94,18 @@ impl WebApiClient { }) .await?; - let status = res.status(); - let body = res.text().await?; + let body = res.text().await.map_err(anyhow::Error::from)?; - if status.is_success() { - let operation: WebAssetOperation = serde_json::from_str(&body)?; + let operation: Operation = serde_json::from_str(&body).map_err(anyhow::Error::from)?; - match self.poll_operation(operation.operation_id, &api_key).await { - Ok(Some(id)) => Ok(id), - Ok(None) => bail!("Failed to get asset ID"), - Err(e) => Err(e), - } - } else { - bail!("Failed to upload asset: {} - {}", status, body) - } + let id = self + .poll_operation(operation.operation_id, &api_key) + .await?; + + Ok(id) } - async fn poll_operation(&self, id: String, api_key: &str) -> anyhow::Result> { + async fn poll_operation(&self, id: String, api_key: &str) -> Result { let mut delay = Duration::from_secs(1); const MAX_POLLS: u32 = 10; @@ -111,19 +119,19 @@ impl WebApiClient { .await?; let status = res.status(); - let text = res.text().await?; + let text = res.text().await.map_err(anyhow::Error::from)?; if !status.is_success() { - bail!("Failed to poll operation: {} - {}", status, text); + return Err(anyhow!("Failed to poll operation: {} - {}", status, text).into()); } - let operation: WebAssetOperation = serde_json::from_str(&text)?; + let operation: Operation = serde_json::from_str(&text).map_err(anyhow::Error::from)?; if operation.done { if let Some(response) = operation.response { - return Ok(Some(response.asset_id.parse()?)); + return Ok(response.asset_id.parse().map_err(anyhow::Error::from)?); } else { - bail!("Operation completed but no response provided") + return Err(anyhow!("Operation completed but no response provided").into()); } } @@ -135,10 +143,10 @@ impl WebApiClient { } } - bail!("Operation polling exceeded maximum retries") + Err(anyhow!("Operation polling exceeded maximum retries").into()) } - async fn send_with_retry(&self, make_req: F) -> anyhow::Result + async fn send_with_retry(&self, make_req: F) -> Result where F: Fn() -> RequestBuilder, { @@ -146,9 +154,10 @@ impl WebApiClient { let mut attempt = 0; loop { - let res = make_req().send().await?; + let res = make_req().send().await.map_err(anyhow::Error::from)?; + let status = res.status(); - match res.status() { + match status { StatusCode::TOO_MANY_REQUESTS if attempt < MAX => { let wait = res .headers() @@ -168,7 +177,17 @@ impl WebApiClient { continue; } - _ => return Ok(res), + StatusCode::OK => return Ok(res), + _ => { + let body = res.text().await.map_err(anyhow::Error::from)?; + let message = extract_error_message(&body); + + return Err(UploadError::Fatal { + status, + message, + body, + }); + } } } } @@ -176,34 +195,34 @@ impl WebApiClient { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct WebAssetRequest { +struct Request { asset_type: AssetType, display_name: String, description: &'static str, - creation_context: WebAssetRequestCreationContext, + creation_context: CreationContext, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct WebAssetRequestCreationContext { - creator: WebAssetCreator, +struct CreationContext { + creator: Creator, expected_price: Option, } #[derive(Serialize)] #[serde(untagged)] -enum WebAssetCreator { - User(WebAssetUserCreator), - Group(WebAssetGroupCreator), +enum Creator { + User(UserCreator), + Group(GroupCreator), } -impl From for WebAssetCreator { - fn from(value: Creator) -> Self { +impl From for Creator { + fn from(value: config::Creator) -> Self { match value.ty { - CreatorType::User => WebAssetCreator::User(WebAssetUserCreator { + config::CreatorType::User => Creator::User(UserCreator { user_id: value.id.to_string(), }), - CreatorType::Group => WebAssetCreator::Group(WebAssetGroupCreator { + config::CreatorType::Group => Creator::Group(GroupCreator { group_id: value.id.to_string(), }), } @@ -212,27 +231,27 @@ impl From for WebAssetCreator { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct WebAssetUserCreator { +struct UserCreator { user_id: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct WebAssetGroupCreator { +struct GroupCreator { group_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct WebAssetOperation { +struct Operation { done: bool, operation_id: String, - response: Option, + response: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct WebAssetOperationResponse { +struct OperationResponse { asset_id: String, } @@ -245,3 +264,18 @@ fn trim_display_name(name: &str) -> String { full_path } } + +#[derive(Deserialize)] +struct ErrorBody { + errors: Vec, +} + +#[derive(Deserialize)] +struct ErrorItem { + message: String, +} + +fn extract_error_message(body: &str) -> String { + let error_body: ErrorBody = serde_json::from_str(body).unwrap(); + error_body.errors[0].message.clone() +} From 7279ccc29442ea14032b946120c8be5d984a2d4b Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Wed, 17 Dec 2025 15:16:55 -0500 Subject: [PATCH 02/10] Rewrite sync orchestration --- Cargo.toml | 2 +- src/asset.rs | 28 ++++- src/auth.rs | 28 ----- src/cli.rs | 3 +- src/main.rs | 1 - src/sync/backend/cloud.rs | 4 +- src/sync/backend/mod.rs | 7 +- src/sync/codegen.rs | 10 +- src/sync/mod.rs | 216 +++++++++++--------------------------- src/sync/perform.rs | 13 +-- src/upload.rs | 5 +- src/web_api.rs | 12 ++- 12 files changed, 115 insertions(+), 214 deletions(-) delete mode 100644 src/auth.rs diff --git a/Cargo.toml b/Cargo.toml index 7923060..d91af0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ anyhow = "1.0.100" bit-vec = "0.8" blake3 = "1.8.2" bytes = "1.10.1" -clap = { version = "4.5.50", features = ["derive"] } +clap = { version = "4.5.50", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" dashmap = "6.1.0" dotenvy = "0.15.7" diff --git a/src/asset.rs b/src/asset.rs index c2de6f8..008e819 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1,4 +1,7 @@ -use crate::util::{alpha_bleed::alpha_bleed, svg::svg_to_png}; +use crate::{ + config::WebAsset, + util::{alpha_bleed::alpha_bleed, svg::svg_to_png}, +}; use anyhow::{Context, bail}; use blake3::Hasher; use bytes::Bytes; @@ -6,7 +9,7 @@ use image::DynamicImage; use relative_path::RelativePathBuf; use resvg::usvg::fontdb::Database; use serde::Serialize; -use std::{io::Cursor, sync::Arc}; +use std::{fmt, io::Cursor, sync::Arc}; pub struct Asset { /// Relative to Input prefix @@ -204,3 +207,24 @@ pub enum RobloxModelFormat { Binary, Xml, } + +#[derive(Debug, Clone)] +pub enum AssetRef { + Cloud(u64), + Studio(String), +} + +impl fmt::Display for AssetRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AssetRef::Cloud(id) => write!(f, "rbxassetid://{id}"), + AssetRef::Studio(name) => write!(f, "rbxasset://{name}"), + } + } +} + +impl From for AssetRef { + fn from(value: WebAsset) -> Self { + AssetRef::Cloud(value.id) + } +} diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index ea7245d..0000000 --- a/src/auth.rs +++ /dev/null @@ -1,28 +0,0 @@ -use anyhow::bail; -use std::env; - -pub struct Auth { - pub api_key: Option, -} - -impl Auth { - pub fn new(arg_key: Option, auth_required: bool) -> anyhow::Result { - let env_key = env::var("ASPHALT_API_KEY").ok(); - - let api_key = match arg_key.or(env_key) { - Some(key) => Some(key), - None if auth_required => { - bail!(err_str("API key")) - } - None => None, - }; - - Ok(Self { api_key }) - } -} - -fn err_str(ty: &str) -> String { - format!( - "A {ty} is required to use Asphalt. See the README for more information:\nhttps://github.com/jackTabsCode/asphalt?tab=readme-ov-file#authentication", - ) -} diff --git a/src/cli.rs b/src/cli.rs index 2b62f61..d84f105 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,8 +42,7 @@ pub enum SyncTarget { #[derive(Args, Clone)] pub struct SyncArgs { /// Your Open Cloud API key. - /// Can also be set with the ASPHALT_API_KEY environment variable. - #[arg(short, long)] + #[arg(short, long, env = "ASPHALT_API_KEY")] pub api_key: Option, /// Where Asphalt should sync assets to. diff --git a/src/main.rs b/src/main.rs index 6f0b40c..5000402 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,6 @@ use upload::upload; use crate::config::Config; mod asset; -mod auth; mod cli; mod config; mod glob; diff --git a/src/sync/backend/cloud.rs b/src/sync/backend/cloud.rs index 4806913..a526b1e 100644 --- a/src/sync/backend/cloud.rs +++ b/src/sync/backend/cloud.rs @@ -1,6 +1,6 @@ -use super::{AssetRef, SyncBackend}; +use super::SyncBackend; use crate::{ - asset::Asset, + asset::{Asset, AssetRef}, sync::{SyncState, backend::SyncError}, web_api::UploadError, }; diff --git a/src/sync/backend/mod.rs b/src/sync/backend/mod.rs index 2b54f79..0552cdb 100644 --- a/src/sync/backend/mod.rs +++ b/src/sync/backend/mod.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::SyncState; -use crate::asset::Asset; +use crate::asset::{Asset, AssetRef}; pub mod cloud; pub mod debug; @@ -20,11 +20,6 @@ pub trait SyncBackend { ) -> Result, SyncError>; } -pub enum AssetRef { - Cloud(u64), - Studio(String), -} - #[derive(Debug, thiserror::Error)] pub enum SyncError { #[error("Fatal error: {0}")] diff --git a/src/sync/codegen.rs b/src/sync/codegen.rs index 0bd97fb..7962363 100644 --- a/src/sync/codegen.rs +++ b/src/sync/codegen.rs @@ -1,4 +1,4 @@ -use crate::config; +use crate::{asset::AssetRef, config}; use anyhow::bail; use relative_path::{RelativePath, RelativePathBuf}; use std::{collections::BTreeMap, path::Path}; @@ -16,14 +16,16 @@ pub enum Language { Luau, } -pub fn create_node(source: &BTreeMap, config: &config::Codegen) -> Node { +pub type NodeSource = BTreeMap; + +pub fn create_node(source: &NodeSource, config: &config::Codegen) -> Node { let mut root = Node::Table(BTreeMap::new()); for (path, value) in source { let value = if config.content { - Node::Content(value.into()) + Node::Content(value.to_string()) } else { - Node::String(value.into()) + Node::String(value.to_string()) }; match config.style { diff --git a/src/sync/mod.rs b/src/sync/mod.rs index dbf20c7..8fa54d9 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,20 +1,17 @@ use crate::{ - auth::Auth, + asset::AssetRef, cli::{SyncArgs, SyncTarget}, - config::{Config, Input}, + config::Config, lockfile::{Lockfile, LockfileEntry, RawLockfile}, + sync::codegen::NodeSource, web_api::WebApiClient, }; use anyhow::{Context, Result, bail}; -use backend::AssetRef; use indicatif::MultiProgress; use log::{info, warn}; use relative_path::RelativePathBuf; use resvg::usvg::fontdb; -use std::{ - collections::{BTreeMap, HashMap}, - sync::Arc, -}; +use std::{collections::HashMap, sync::Arc}; use tokio::{ fs, sync::mpsc::{self, Receiver, Sender}, @@ -29,29 +26,30 @@ mod walk; pub struct SyncState { args: SyncArgs, - existing_lockfile: Lockfile, - result_tx: mpsc::Sender, - + event_tx: Sender, multi_progress: MultiProgress, - font_db: Arc, - client: WebApiClient, } +#[derive(Debug)] +pub struct SyncEvent { + write_lockfile: bool, + input_name: String, + path: RelativePathBuf, + hash: String, + asset_ref: AssetRef, +} + pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { if args.dry_run && !matches!(args.target, SyncTarget::Cloud) { bail!("A dry run doesn't make sense in this context"); } let config = Config::read().await?; - let codegen_config = config.codegen.clone(); - - let lockfile = RawLockfile::read().await?.into_lockfile()?; - let key_required = matches!(args.target, SyncTarget::Cloud) && !args.dry_run; - let auth = Auth::new(args.api_key.clone(), key_required)?; + let existing_lockfile = RawLockfile::read().await?.into_lockfile()?; let font_db = Arc::new({ let mut db = fontdb::Database::new(); @@ -59,38 +57,20 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { db }); - let (codegen_tx, codegen_rx) = mpsc::channel::(100); - - let codegen_handle = { - let inputs = config.inputs.clone(); - tokio::spawn(async move { collect_codegen_insertions(codegen_rx, inputs).await }) - }; + let (event_tx, event_rx) = mpsc::channel::(100); - let (lockfile_tx, lockfile_rx) = mpsc::channel::(100); - - let lockfile_handle = - tokio::spawn(async move { collect_lockfile_insertions(lockfile_rx).await }); - - let (result_tx, result_rx) = mpsc::channel::(100); - - let result_handle = { - let codegen_tx = codegen_tx.clone(); - let lockfile_tx = lockfile_tx.clone(); - - tokio::spawn(async move { handle_sync_results(result_rx, codegen_tx, lockfile_tx).await }) - }; + let collector_handle = tokio::spawn({ + let config = config.clone(); + async move { collect_events(event_rx, config).await } + }); let state = Arc::new(SyncState { args: args.clone(), - - existing_lockfile: lockfile, - result_tx, - + existing_lockfile, + event_tx, multi_progress, - font_db, - - client: WebApiClient::new(auth, config.creator, args.expected_price), + client: WebApiClient::new(args.api_key, config.creator, args.expected_price), }); let mut duplicate_assets = HashMap::>::new(); @@ -111,23 +91,14 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { continue; } - if matches!(args.target, SyncTarget::Cloud) { - lockfile_tx - .send(LockfileInsertion { - input_name: input_name.clone(), - hash: existing.hash, - entry: existing.entry.clone(), - // This takes too long, and we're not really losing anything here. - write: false, - }) - .await?; - } - - codegen_tx - .send(CodegenInsertion { + state + .event_tx + .send(SyncEvent { + write_lockfile: false, input_name: input_name.clone(), - asset_path: existing.path.clone(), - asset_id: format!("rbxassetid://{}", existing.entry.asset_id), + path: existing.path, + hash: existing.hash, + asset_ref: AssetRef::Cloud(existing.entry.asset_id), }) .await?; } @@ -179,23 +150,19 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { drop(state); - result_handle.await??; - - drop(codegen_tx); - drop(lockfile_tx); + let (new_lockfile, mut inputs_to_sources) = collector_handle.await??; - let new_lockfile = lockfile_handle.await??; if matches!(args.target, SyncTarget::Cloud) { new_lockfile.write(None).await?; } - let mut inputs_to_sources = codegen_handle.await??; - for (input_name, dupes) in duplicate_assets { let source = inputs_to_sources.get_mut(&input_name).unwrap(); for dupe in dupes { - let original = source.get(&dupe.original_path).unwrap(); + let original = source + .get(&dupe.original_path) + .expect("We marked a duplicate, but there was no source"); source.insert(dupe.path, original.clone()); } } @@ -208,7 +175,7 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { let mut langs_to_generate = vec![codegen::Language::Luau]; - if codegen_config.typescript { + if config.codegen.typescript { langs_to_generate.push(codegen::Language::TypeScript); } @@ -228,99 +195,40 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { Ok(()) } -pub struct SyncResult { - hash: String, - path: RelativePathBuf, - input_name: String, - asset_ref: AssetRef, -} +async fn collect_events( + mut rx: Receiver, + config: Config, +) -> Result<(Lockfile, HashMap)> { + let mut lockfile = Lockfile::default(); -async fn handle_sync_results( - mut rx: Receiver, - codegen_tx: Sender, - lockfile_tx: Sender, -) -> anyhow::Result<()> { - while let Some(result) = rx.recv().await { - if let AssetRef::Cloud(asset_id) = result.asset_ref { - lockfile_tx - .send(LockfileInsertion { - input_name: result.input_name.clone(), - hash: result.hash, - entry: LockfileEntry { asset_id }, - write: true, - }) - .await?; - - codegen_tx - .send(CodegenInsertion { - input_name: result.input_name, - asset_path: result.path, - asset_id: format!("rbxassetid://{asset_id}"), - }) - .await?; - } else if let AssetRef::Studio(asset_id) = result.asset_ref { - codegen_tx - .send(CodegenInsertion { - input_name: result.input_name, - asset_path: result.path.clone(), - asset_id, - }) - .await?; + let mut inputs_to_sources: HashMap = HashMap::new(); + for (input_name, input) in &config.inputs { + for (rel_path, web_asset) in &input.web { + inputs_to_sources + .entry(input_name.clone()) + .or_default() + .insert(rel_path.clone(), web_asset.clone().into()); } } - Ok(()) -} - -struct CodegenInsertion { - input_name: String, - asset_path: RelativePathBuf, - asset_id: String, -} - -async fn collect_codegen_insertions( - mut rx: Receiver, - inputs: HashMap, -) -> anyhow::Result>> { - let mut inputs_to_sources: HashMap> = HashMap::new(); - - for (input_name, input) in &inputs { - for (rel_path, asset) in &input.web { - let entry = inputs_to_sources.entry(input_name.clone()).or_default(); - - entry.insert(rel_path.clone(), format!("rbxassetid://{}", asset.id)); + while let Some(event) = rx.recv().await { + inputs_to_sources + .entry(event.input_name.clone()) + .or_default() + .insert(event.path, event.asset_ref.clone()); + + if let AssetRef::Cloud(id) = event.asset_ref { + lockfile.insert( + &event.input_name, + &event.hash, + LockfileEntry { asset_id: id }, + ); } - } - - while let Some(insertion) = rx.recv().await { - let source = inputs_to_sources - .entry(insertion.input_name.clone()) - .or_default(); - - source.insert(insertion.asset_path, insertion.asset_id); - } - - Ok(inputs_to_sources) -} - -struct LockfileInsertion { - input_name: String, - hash: String, - entry: LockfileEntry, - write: bool, -} - -async fn collect_lockfile_insertions( - mut rx: Receiver, -) -> anyhow::Result { - let mut new_lockfile = Lockfile::default(); - while let Some(insertion) = rx.recv().await { - new_lockfile.insert(&insertion.input_name, &insertion.hash, insertion.entry); - if insertion.write { - new_lockfile.write(None).await?; + if event.write_lockfile { + lockfile.write(None).await?; } } - Ok(new_lockfile) + Ok((lockfile, inputs_to_sources)) } diff --git a/src/sync/perform.rs b/src/sync/perform.rs index ae43bbf..1754cd3 100644 --- a/src/sync/perform.rs +++ b/src/sync/perform.rs @@ -6,7 +6,7 @@ use crate::{ asset::Asset, cli::SyncTarget, progress_bar::ProgressBar, - sync::{SyncResult, backend::SyncError}, + sync::{SyncEvent, backend::SyncError}, }; use anyhow::bail; use log::warn; @@ -46,14 +46,15 @@ pub async fn perform( match res { Ok(Some(asset_ref)) => { state - .result_tx - .send(SyncResult { - input_name: input_name.clone(), - hash: asset.hash.clone(), + .event_tx + .send(SyncEvent { path: asset.path.clone(), asset_ref, + write_lockfile: matches!(backend, TargetBackend::Cloud(_)), + hash: asset.hash.clone(), + input_name, }) - .await?; + .await? } Ok(None) => {} Err(SyncError::Fatal(err)) => { diff --git a/src/upload.rs b/src/upload.rs index 451578e..98fc561 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -1,4 +1,4 @@ -use crate::{asset::Asset, auth::Auth, cli::UploadArgs, config::Creator, web_api::WebApiClient}; +use crate::{asset::Asset, cli::UploadArgs, config::Creator, web_api::WebApiClient}; use fs_err::tokio as fs; use relative_path::PathExt; use resvg::usvg::fontdb::Database; @@ -19,9 +19,8 @@ pub async fn upload(args: UploadArgs) -> anyhow::Result<()> { ty: args.creator_type, id: args.creator_id, }; - let auth = Auth::new(args.api_key, true)?; - let client = WebApiClient::new(auth, creator, args.expected_price); + let client = WebApiClient::new(args.api_key, creator, args.expected_price); let asset_id = client.upload(&asset).await?; diff --git a/src/web_api.rs b/src/web_api.rs index 12b1cb6..92a73f9 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -1,6 +1,5 @@ use crate::{ asset::{Asset, AssetType}, - auth::Auth, config, }; use anyhow::{Context, anyhow}; @@ -33,16 +32,20 @@ pub enum UploadError { pub struct WebApiClient { inner: reqwest::Client, - auth: Auth, + api_key: Option, creator: config::Creator, expected_price: Option, } impl WebApiClient { - pub fn new(auth: Auth, creator: config::Creator, expected_price: Option) -> Self { + pub fn new( + api_key: Option, + creator: config::Creator, + expected_price: Option, + ) -> Self { WebApiClient { inner: reqwest::Client::new(), - auth, + api_key, creator, expected_price, } @@ -50,7 +53,6 @@ impl WebApiClient { pub async fn upload(&self, asset: &Asset) -> Result { let api_key = self - .auth .api_key .clone() .context("An API key is necessary to upload")?; From 151686cfc64242187980abcbb0f5422f3059fb88 Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Tue, 30 Dec 2025 17:20:44 -0500 Subject: [PATCH 03/10] Set up basic integration tests --- Cargo.lock | 183 +++++++++++++++++++++++++- Cargo.toml | 6 +- src/sync/backend/cloud.rs | 6 - src/web_api.rs | 6 +- tests/assets/README.md | 1 + tests/assets/test1.png | Bin 0 -> 57909 bytes tests/assets/test2.jpg | Bin 0 -> 95896 bytes tests/common/mod.rs | 47 +++++++ tests/sync.rs | 264 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 502 insertions(+), 11 deletions(-) create mode 100644 tests/assets/README.md create mode 100644 tests/assets/test1.png create mode 100644 tests/assets/test2.jpg create mode 100644 tests/common/mod.rs create mode 100644 tests/sync.rs diff --git a/Cargo.lock b/Cargo.lock index 38503e1..4fb5c6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,8 @@ name = "asphalt" version = "1.2.0" dependencies = [ "anyhow", + "assert_cmd", + "assert_fs", "bit-vec", "blake3", "bytes", @@ -145,6 +147,7 @@ dependencies = [ "indicatif-log-bridge", "insta", "log", + "predicates", "rbx_binary", "rbx_xml", "relative-path", @@ -160,6 +163,36 @@ dependencies = [ "walkdir", ] +[[package]] +name = "assert_cmd" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "async-compression" version = "0.4.32" @@ -270,6 +303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -528,6 +562,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "dirs" version = "2.0.2" @@ -581,6 +621,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dotenvy" version = "0.15.7" @@ -654,6 +700,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "exr" version = "1.73.0" @@ -669,6 +725,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fax" version = "0.2.6" @@ -720,6 +782,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -908,6 +979,17 @@ dependencies = [ "serde", ] +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.10.0", + "ignore", + "walkdir", +] + [[package]] name = "half" version = "2.7.0" @@ -1146,6 +1228,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.8" @@ -1383,6 +1481,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.0" @@ -1528,6 +1632,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-bigint" version = "0.4.6" @@ -1714,6 +1824,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp 0.10.0", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -2254,6 +2394,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.32" @@ -2537,7 +2690,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "float-cmp", + "float-cmp 0.9.0", ] [[package]] @@ -2612,6 +2765,25 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -3079,6 +3251,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index d91af0e..fb4716d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,10 +44,10 @@ toml = "0.9.8" walkdir = "2.5.0" [dev-dependencies] +assert_cmd = "2.1.1" +assert_fs = "1.1.3" insta = { version = "1.43.2", features = ["yaml"] } - -[features] -mock_cloud = [] +predicates = "3.1.3" [profile.dev.package] insta.opt-level = 3 diff --git a/src/sync/backend/cloud.rs b/src/sync/backend/cloud.rs index a526b1e..6fc7934 100644 --- a/src/sync/backend/cloud.rs +++ b/src/sync/backend/cloud.rs @@ -6,7 +6,6 @@ use crate::{ }; use anyhow::anyhow; use std::sync::Arc; -use tokio::time; pub struct CloudBackend; @@ -24,11 +23,6 @@ impl SyncBackend for CloudBackend { _input_name: String, asset: &Asset, ) -> Result, SyncError> { - if cfg!(feature = "mock_cloud") { - time::sleep(time::Duration::from_secs(1)).await; - return Ok(Some(AssetRef::Cloud(1337))); - } - match state.client.upload(asset).await { Ok(id) => Ok(Some(AssetRef::Cloud(id))), Err(UploadError::Fatal { message, .. }) => Err(SyncError::Fatal(anyhow!(message))), diff --git a/src/web_api.rs b/src/web_api.rs index 92a73f9..2ef658f 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -10,7 +10,7 @@ use reqwest::{ multipart, }; use serde::{Deserialize, Serialize}; -use std::time::Duration; +use std::{env, time::Duration}; const UPLOAD_URL: &str = "https://apis.roblox.com/assets/v1/assets"; const OPERATION_URL: &str = "https://apis.roblox.com/assets/v1/operations"; @@ -52,6 +52,10 @@ impl WebApiClient { } pub async fn upload(&self, asset: &Asset) -> Result { + if env::var("ASPHALT_TEST").is_ok() { + return Ok(1337); + } + let api_key = self .api_key .clone() diff --git a/tests/assets/README.md b/tests/assets/README.md new file mode 100644 index 0000000..54c6c9f --- /dev/null +++ b/tests/assets/README.md @@ -0,0 +1 @@ +Assets are sourced from https://samplelib.com/ diff --git a/tests/assets/test1.png b/tests/assets/test1.png new file mode 100644 index 0000000000000000000000000000000000000000..785c3150e90f4304669d35d252d6de4daa632cd7 GIT binary patch literal 57909 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 literal 0 HcmV?d00001 diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..81ea715 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,47 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use assert_fs::{TempDir, fixture::ChildPath, prelude::*}; +use std::{fs, path::Path}; + +pub struct Project { + pub dir: TempDir, +} + +impl Project { + pub fn new() -> Self { + Self { + dir: TempDir::new().unwrap(), + } + } + + pub fn write_config(&self, contents: toml::Table) { + self.dir + .child("asphalt.toml") + .write_str(&contents.to_string()) + .unwrap(); + } + + pub fn write_lockfile(&self, contents: toml::Table) { + self.dir + .child("asphalt.lock.toml") + .write_str(&contents.to_string()) + .unwrap(); + } + + fn read_test_asset(&self, file_name: &str) -> Vec { + let path = Path::new("tests").join("assets").join(file_name); + fs::read(&path).unwrap() + } + + pub fn add_file(&self, file_name: &str) -> ChildPath { + let file = self.dir.child("input").child(file_name); + file.write_binary(&self.read_test_asset(file_name)).unwrap(); + file + } + + pub fn run(&self) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!(); + cmd.env("ASPHALT_TEST", "true"); + cmd.current_dir(self.dir.path()); + cmd + } +} diff --git a/tests/sync.rs b/tests/sync.rs new file mode 100644 index 0000000..25a5066 --- /dev/null +++ b/tests/sync.rs @@ -0,0 +1,264 @@ +use assert_fs::{fixture::ChildPath, prelude::*}; +use common::Project; +use predicates::{Predicate, prelude::predicate, str::contains}; +use std::{fs, path::Path}; +use toml::toml; + +mod common; + +fn hash(path: &ChildPath) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(&fs::read(path).unwrap()); + hasher.finalize().to_string() +} + +fn toml_eq(expected: toml::Value) -> impl Predicate { + predicate::function(move |path: &Path| { + let contents = fs::read_to_string(path).unwrap(); + let actual: toml::Value = toml::from_str(&contents).unwrap(); + actual == expected + }) +} + +#[test] +fn missing_config_fails() { + Project::new() + .run() + .args(["sync", "--target", "debug"]) + .assert() + .failure(); +} + +#[test] +fn debug_creates_output() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 1234 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + bleed = false + }); + let test_file = project.add_file("test1.png"); + + project + .run() + .args(["sync", "--target", "debug"]) + .assert() + .success(); + + project + .dir + .child(".asphalt-debug/test1.png") + .assert(predicate::path::eq_file(test_file.path())); +} + +#[test] +fn debug_web_assets() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 12345 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + + [inputs.assets.web] + "existing.png" = { id = 1234 } + }); + + project + .run() + .args(["sync", "--target", "debug"]) + .assert() + .success(); + + project + .dir + .child("output/assets.luau") + .assert(contains("existing.png")) + .assert(contains("1234")); +} + +#[test] +fn cloud_output_and_lockfile() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 12345 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + }); + let test_file = project.add_file("test1.png"); + + project + .run() + .args(["sync", "--api-key", "test"]) + .assert() + .success(); + + project.dir.child("asphalt.lock.toml").assert(toml_eq({ + let mut table = toml::Table::new(); + table.insert("version".into(), 2.into()); + + table.insert("inputs".into(), { + let mut inputs = toml::Table::new(); + inputs.insert("assets".into(), { + let mut assets = toml::Table::new(); + assets.insert(hash(&test_file), { + let mut entry = toml::Table::new(); + entry.insert("asset_id".into(), 1337.into()); + entry.into() + }); + assets.into() + }); + inputs.into() + }); + + table.into() + })); +} + +#[test] +fn dry_run_none() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 12345 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + }); + + project + .run() + .args(["sync", "--dry-run"]) + .assert() + .success() + .stderr(contains("No new assets")); +} + +#[test] +fn dry_run_1_new() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 12345 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + }); + project.add_file("test1.png"); + + project + .run() + .args(["sync", "--dry-run"]) + .assert() + .failure() + .stderr(contains("1 new assets")); +} + +#[test] +fn dry_run_1_new_1_old() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 12345 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + }); + let old_file = project.add_file("test1.png"); + project.add_file("test2.jpg"); + + project.write_lockfile({ + let mut table = toml::Table::new(); + table.insert("version".into(), 2.into()); + + table.insert("inputs".into(), { + let mut inputs = toml::Table::new(); + inputs.insert("assets".into(), { + let mut assets = toml::Table::new(); + assets.insert(hash(&old_file), { + let mut entry = toml::Table::new(); + entry.insert("asset_id".into(), toml::Value::Integer(1)); + entry.into() + }); + assets.into() + }); + inputs.into() + }); + + table + }); + + project + .run() + .args(["sync", "--dry-run"]) + .assert() + .failure() + .stderr(contains("1 new assets")); +} + +#[test] +fn dry_run_2_old() { + let project = Project::new(); + project.write_config(toml! { + [creator] + type = "user" + id = 12345 + + [inputs.assets] + path = "input/**/*" + output_path = "output" + }); + let old_file_1 = project.add_file("test1.png"); + let old_file_2 = project.add_file("test2.jpg"); + + project.write_lockfile({ + let mut table = toml::Table::new(); + table.insert("version".into(), 2.into()); + + table.insert("inputs".into(), { + let mut inputs = toml::Table::new(); + inputs.insert("assets".into(), { + let mut assets = toml::Table::new(); + assets.insert(hash(&old_file_1), { + let mut entry = toml::Table::new(); + entry.insert("asset_id".into(), toml::Value::Integer(1)); + entry.into() + }); + assets.insert(hash(&old_file_2), { + let mut entry = toml::Table::new(); + entry.insert("asset_id".into(), toml::Value::Integer(1)); + entry.into() + }); + assets.into() + }); + inputs.into() + }); + + table + }); + + project + .run() + .args(["sync", "--dry-run"]) + .assert() + .success() + .stderr(contains("No new assets")); +} From d1e411062d49e4166eeb167a5b24c303f51f7c66 Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Wed, 31 Dec 2025 20:08:01 -0500 Subject: [PATCH 04/10] Refactor sync orchestration again --- src/asset.rs | 78 ++++--- src/config.rs | 7 +- src/lockfile.rs | 10 +- src/sync/backend/cloud.rs | 37 ++-- src/sync/backend/debug.rs | 16 +- src/sync/backend/mod.rs | 37 ++-- src/sync/backend/studio.rs | 29 ++- src/sync/mod.rs | 412 +++++++++++++++++++++++++------------ src/sync/perform.rs | 88 -------- src/sync/process.rs | 37 ---- src/sync/walk.rs | 135 ------------ src/upload.rs | 10 +- src/web_api.rs | 112 ++++------ tests/common/mod.rs | 1 + 14 files changed, 443 insertions(+), 566 deletions(-) delete mode 100644 src/sync/perform.rs delete mode 100644 src/sync/process.rs delete mode 100644 src/sync/walk.rs diff --git a/src/asset.rs b/src/asset.rs index 008e819..3887913 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -9,7 +9,47 @@ use image::DynamicImage; use relative_path::RelativePathBuf; use resvg::usvg::fontdb::Database; use serde::Serialize; -use std::{fmt, io::Cursor, sync::Arc}; +use std::{ffi::OsStr, fmt, io::Cursor, sync::Arc}; + +type AssetCtor = fn(&[u8]) -> anyhow::Result; + +const SUPPORTED_EXTENSIONS: &[(&str, AssetCtor)] = &[ + ("mp3", |_| Ok(AssetType::Audio(AudioType::Mp3))), + ("ogg", |_| Ok(AssetType::Audio(AudioType::Ogg))), + ("flac", |_| Ok(AssetType::Audio(AudioType::Flac))), + ("wav", |_| Ok(AssetType::Audio(AudioType::Wav))), + ("png", |_| Ok(AssetType::Image(ImageType::Png))), + ("svg", |_| Ok(AssetType::Image(ImageType::Png))), + ("jpg", |_| Ok(AssetType::Image(ImageType::Jpg))), + ("jpeg", |_| Ok(AssetType::Image(ImageType::Jpg))), + ("bmp", |_| Ok(AssetType::Image(ImageType::Bmp))), + ("tga", |_| Ok(AssetType::Image(ImageType::Tga))), + ("fbx", |_| Ok(AssetType::Model(ModelType::Fbx))), + ("gltf", |_| Ok(AssetType::Model(ModelType::GltfJson))), + ("glb", |_| Ok(AssetType::Model(ModelType::GltfBinary))), + ("rbxm", |data| { + let format = RobloxModelFormat::Binary; + if is_animation(data, &format)? { + Ok(AssetType::Animation) + } else { + Ok(AssetType::Model(ModelType::Roblox)) + } + }), + ("rbxmx", |data| { + let format = RobloxModelFormat::Xml; + if is_animation(data, &format)? { + Ok(AssetType::Animation) + } else { + Ok(AssetType::Model(ModelType::Roblox)) + } + }), + ("mp4", |_| Ok(AssetType::Video(VideoType::Mp4))), + ("mov", |_| Ok(AssetType::Video(VideoType::Mov))), +]; + +pub fn is_supported_extension(ext: &OsStr) -> bool { + SUPPORTED_EXTENSIONS.iter().any(|(e, _)| *e == ext) +} pub struct Asset { /// Relative to Input prefix @@ -23,41 +63,17 @@ pub struct Asset { } impl Asset { - pub fn new(path: RelativePathBuf, data: Vec) -> anyhow::Result { + pub async fn new(path: RelativePathBuf, data: Vec) -> anyhow::Result { let ext = path .extension() .context("File has no extension")? .to_string(); - let ty = match ext.as_str() { - "mp3" => AssetType::Audio(AudioType::Mp3), - "ogg" => AssetType::Audio(AudioType::Ogg), - "flac" => AssetType::Audio(AudioType::Flac), - "wav" => AssetType::Audio(AudioType::Wav), - "png" | "svg" => AssetType::Image(ImageType::Png), - "jpg" | "jpeg" => AssetType::Image(ImageType::Jpg), - "bmp" => AssetType::Image(ImageType::Bmp), - "tga" => AssetType::Image(ImageType::Tga), - "fbx" => AssetType::Model(ModelType::Fbx), - "gltf" => AssetType::Model(ModelType::GltfJson), - "glb" => AssetType::Model(ModelType::GltfBinary), - "rbxm" | "rbxmx" => { - let format = if ext == "rbxm" { - RobloxModelFormat::Binary - } else { - RobloxModelFormat::Xml - }; - - if is_animation(&data, &format)? { - AssetType::Animation - } else { - AssetType::Model(ModelType::Roblox) - } - } - "mp4" => AssetType::Video(VideoType::Mp4), - "mov" => AssetType::Video(VideoType::Mov), - _ => bail!("Unknown extension .{ext}"), - }; + let ty = SUPPORTED_EXTENSIONS + .iter() + .find(|(e, _)| *e == ext) + .map(|(_, func)| func(&data)) + .context("Unknown file type")??; let data = Bytes::from(data); diff --git a/src/config.rs b/src/config.rs index 224c128..f0b42fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -73,7 +73,8 @@ fn default_true() -> bool { pub struct Input { /// A glob pattern to match files to upload #[schemars(with = "String")] - pub path: Glob, + #[serde(rename = "path")] + pub include: Glob, /// The directory path to output the generated code pub output_path: PathBuf, @@ -85,10 +86,6 @@ pub struct Input { #[serde(default)] #[schemars(with = "HashMap")] pub web: HashMap, - - /// Emit a warning each time a duplicate file is found - #[serde(default = "default_true")] - pub warn_each_duplicate: bool, } /// An asset that exists on Roblox diff --git a/src/lockfile.rs b/src/lockfile.rs index eb28a09..e5c8aaf 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result, bail}; +use anyhow::{Context, bail}; use blake3::Hasher; use fs_err::tokio as fs; use serde::{Deserialize, Serialize}; @@ -41,7 +41,7 @@ impl Lockfile { .insert(hash.to_owned(), entry); } - pub async fn write(&self, filename: Option<&Path>) -> Result<()> { + pub async fn write(&self, filename: Option<&Path>) -> anyhow::Result<()> { let mut content = toml::to_string(self)?; content.insert_str(0, "# This file is automatically @generated by Asphalt.\n# It is not intended for manual editing.\n"); @@ -82,7 +82,7 @@ impl Default for RawLockfile { } impl RawLockfile { - pub async fn read() -> Result { + pub async fn read() -> anyhow::Result { let content = fs::read_to_string(FILE_NAME).await; let content = match content { @@ -107,7 +107,7 @@ impl RawLockfile { } } - pub async fn migrate(self, input_name: Option<&str>) -> Result { + pub async fn migrate(self, input_name: Option<&str>) -> anyhow::Result { match (self, input_name) { (Self::V2(_), _) => bail!("Your lockfile is already up to date"), (Self::V1(v1), _) => Ok(migrate_from_v1(&v1)), @@ -157,7 +157,7 @@ async fn migrate_from_v0(lockfile: &LockfileV0, input_name: &str) -> anyhow::Res Ok(new_lockfile) } -async fn read_and_hash(path: &Path) -> Result { +async fn read_and_hash(path: &Path) -> anyhow::Result { let bytes = fs::read(path).await?; let mut hasher = Hasher::new(); hasher.update(&bytes); diff --git a/src/sync/backend/cloud.rs b/src/sync/backend/cloud.rs index 6fc7934..50a86af 100644 --- a/src/sync/backend/cloud.rs +++ b/src/sync/backend/cloud.rs @@ -1,32 +1,41 @@ -use super::SyncBackend; +use super::Backend; use crate::{ asset::{Asset, AssetRef}, - sync::{SyncState, backend::SyncError}, - web_api::UploadError, + sync::{State, backend::Params}, + web_api::WebApiClient, }; -use anyhow::anyhow; +use anyhow::{Context, bail}; use std::sync::Arc; -pub struct CloudBackend; +pub struct Cloud { + client: WebApiClient, +} -impl SyncBackend for CloudBackend { - async fn new() -> anyhow::Result +impl Backend for Cloud { + async fn new(params: Params) -> anyhow::Result where Self: Sized, { - Ok(Self) + Ok(Self { + client: WebApiClient::new( + params + .api_key + .context("An API key is required to use the Cloud backend")?, + params.creator, + params.expected_price, + ), + }) } async fn sync( &self, - state: Arc, - _input_name: String, + _: Arc, + _: String, asset: &Asset, - ) -> Result, SyncError> { - match state.client.upload(asset).await { + ) -> anyhow::Result> { + match self.client.upload(asset).await { Ok(id) => Ok(Some(AssetRef::Cloud(id))), - Err(UploadError::Fatal { message, .. }) => Err(SyncError::Fatal(anyhow!(message))), - Err(UploadError::Other(e)) => Err(SyncError::Fatal(e)), + Err(err) => bail!("Failed to upload asset: {err:?}"), } } } diff --git a/src/sync/backend/debug.rs b/src/sync/backend/debug.rs index 34ae4fe..3b6c121 100644 --- a/src/sync/backend/debug.rs +++ b/src/sync/backend/debug.rs @@ -1,19 +1,19 @@ -use super::{AssetRef, SyncBackend}; +use super::{AssetRef, Backend}; use crate::{ asset::Asset, - sync::{SyncState, backend::SyncError}, + sync::{State, backend::Params}, }; use anyhow::Context; use fs_err::tokio as fs; use log::info; use std::{env, path::PathBuf, sync::Arc}; -pub struct DebugBackend { +pub struct Debug { sync_path: PathBuf, } -impl SyncBackend for DebugBackend { - async fn new() -> anyhow::Result +impl Backend for Debug { + async fn new(_: Params) -> anyhow::Result where Self: Sized, { @@ -37,10 +37,10 @@ impl SyncBackend for DebugBackend { async fn sync( &self, - _state: Arc, - _input_name: String, + _: Arc, + _: String, asset: &Asset, - ) -> Result, SyncError> { + ) -> anyhow::Result> { let target_path = asset.path.to_logical_path(&self.sync_path); if let Some(parent) = target_path.parent() { diff --git a/src/sync/backend/mod.rs b/src/sync/backend/mod.rs index 0552cdb..6d04a63 100644 --- a/src/sync/backend/mod.rs +++ b/src/sync/backend/mod.rs @@ -1,30 +1,35 @@ use std::sync::Arc; -use super::SyncState; -use crate::asset::{Asset, AssetRef}; +use super::State; +use crate::{ + asset::{Asset, AssetRef}, + config, +}; -pub mod cloud; -pub mod debug; -pub mod studio; +mod cloud; +pub use cloud::Cloud; -pub trait SyncBackend { - async fn new() -> anyhow::Result +mod debug; +pub use debug::Debug; + +mod studio; +pub use studio::Studio; + +pub trait Backend { + async fn new(params: Params) -> anyhow::Result where Self: Sized; async fn sync( &self, - state: Arc, + state: Arc, input_name: String, asset: &Asset, - ) -> Result, SyncError>; + ) -> anyhow::Result>; } -#[derive(Debug, thiserror::Error)] -pub enum SyncError { - #[error("Fatal error: {0}")] - Fatal(anyhow::Error), - - #[error(transparent)] - Other(#[from] anyhow::Error), +pub struct Params { + pub api_key: Option, + pub creator: config::Creator, + pub expected_price: Option, } diff --git a/src/sync/backend/studio.rs b/src/sync/backend/studio.rs index 9e6dca6..65f5fc2 100644 --- a/src/sync/backend/studio.rs +++ b/src/sync/backend/studio.rs @@ -1,7 +1,7 @@ -use super::{AssetRef, SyncBackend}; +use super::{AssetRef, Backend}; use crate::{ asset::{Asset, AssetType}, - sync::{SyncState, backend::SyncError}, + sync::{State, backend::Params}, }; use anyhow::{Context, bail}; use fs_err::tokio as fs; @@ -10,13 +10,13 @@ use relative_path::RelativePathBuf; use roblox_install::RobloxStudio; use std::{env, path::PathBuf, sync::Arc}; -pub struct StudioBackend { +pub struct Studio { identifier: String, sync_path: PathBuf, } -impl SyncBackend for StudioBackend { - async fn new() -> anyhow::Result +impl Backend for Studio { + async fn new(_: Params) -> anyhow::Result where Self: Sized, { @@ -51,10 +51,10 @@ impl SyncBackend for StudioBackend { async fn sync( &self, - state: Arc, + state: Arc, input_name: String, asset: &Asset, - ) -> Result, SyncError> { + ) -> anyhow::Result> { if matches!(asset.ty, AssetType::Model(_) | AssetType::Animation) { return match state.existing_lockfile.get(&input_name, &asset.hash) { Some(entry) => Ok(Some(AssetRef::Studio(format!( @@ -74,14 +74,10 @@ impl SyncBackend for StudioBackend { let target_path = rel_target_path.to_logical_path(&self.sync_path); if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(anyhow::Error::from)?; + fs::create_dir_all(parent).await?; } - fs::write(&target_path, &asset.data) - .await - .map_err(anyhow::Error::from)?; + fs::write(&target_path, &asset.data).await?; Ok(Some(AssetRef::Studio(format!( "rbxasset://{}/{}", @@ -95,7 +91,10 @@ fn get_content_path() -> anyhow::Result { let path = PathBuf::from(var); if path.exists() { - debug!("Using environment variable content path: {path:?}"); + debug!( + "Using environment variable content path: {}", + path.display() + ); return Ok(path); } else { bail!("Content path `{}` does not exist", path.display()); @@ -105,7 +104,7 @@ fn get_content_path() -> anyhow::Result { let studio = RobloxStudio::locate()?; let path = studio.content_path(); - debug!("Using auto-detected content path: {path:?}"); + debug!("Using auto-detected content path: {}", path.display()); Ok(path.to_owned()) } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 8fa54d9..f7f08af 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,48 +1,74 @@ use crate::{ - asset::AssetRef, + asset::{self, Asset, AssetRef}, cli::{SyncArgs, SyncTarget}, config::Config, lockfile::{Lockfile, LockfileEntry, RawLockfile}, - sync::codegen::NodeSource, - web_api::WebApiClient, + sync::{backend::Backend, codegen::NodeSource}, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, bail}; +use dashmap::DashMap; +use futures::{StreamExt, stream}; use indicatif::MultiProgress; -use log::{info, warn}; -use relative_path::RelativePathBuf; +use log::{debug, info, warn}; +use relative_path::{PathExt, RelativePathBuf}; use resvg::usvg::fontdb; -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; use tokio::{ fs, sync::mpsc::{self, Receiver, Sender}, }; -use walk::{DuplicateFile, WalkedFile}; +use walkdir::{DirEntry, WalkDir}; mod backend; mod codegen; -mod perform; -mod process; -mod walk; -pub struct SyncState { +pub struct State { args: SyncArgs, existing_lockfile: Lockfile, - event_tx: Sender, + event_tx: Sender, multi_progress: MultiProgress, font_db: Arc, - client: WebApiClient, + target_backend: TargetBackend, +} + +enum TargetBackend { + Cloud(backend::Cloud), + Debug(backend::Debug), + Studio(backend::Studio), +} + +impl TargetBackend { + pub async fn sync( + &self, + state: Arc, + input_name: String, + asset: &Asset, + ) -> anyhow::Result> { + match self { + Self::Cloud(cloud_backend) => cloud_backend.sync(state, input_name, asset).await, + Self::Debug(debug_backend) => debug_backend.sync(state, input_name, asset).await, + Self::Studio(studio_backend) => studio_backend.sync(state, input_name, asset).await, + } + } } #[derive(Debug)] -pub struct SyncEvent { - write_lockfile: bool, - input_name: String, - path: RelativePathBuf, - hash: String, - asset_ref: AssetRef, +enum Event { + Insert { + new: bool, + input_name: String, + path: RelativePathBuf, + hash: String, + asset_ref: AssetRef, + }, + Duplicate { + input_name: String, + path: RelativePathBuf, + original_path: RelativePathBuf, + }, } -pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { +pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Result<()> { if args.dry_run && !matches!(args.target, SyncTarget::Cloud) { bail!("A dry run doesn't make sense in this context"); } @@ -57,117 +83,59 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { db }); - let (event_tx, event_rx) = mpsc::channel::(100); + let (event_tx, event_rx) = mpsc::channel::(100); let collector_handle = tokio::spawn({ let config = config.clone(); - async move { collect_events(event_rx, config).await } - }); - - let state = Arc::new(SyncState { - args: args.clone(), - existing_lockfile, - event_tx, - multi_progress, - font_db, - client: WebApiClient::new(args.api_key, config.creator, args.expected_price), + async move { collect_events(event_rx, config, args.dry_run).await } }); - let mut duplicate_assets = HashMap::>::new(); - - for (input_name, input) in &config.inputs { - let walk_results = walk::walk(state.clone(), input_name.clone(), input).await?; - - let mut new_assets = Vec::with_capacity(walk_results.len()); - let mut dupe_count = 0; - - for result in walk_results { - match result { - WalkedFile::New(asset) => { - new_assets.push(asset); - } - WalkedFile::Existing(existing) => { - if args.dry_run { - continue; - } - - state - .event_tx - .send(SyncEvent { - write_lockfile: false, - input_name: input_name.clone(), - path: existing.path, - hash: existing.hash, - asset_ref: AssetRef::Cloud(existing.entry.asset_id), - }) - .await?; - } - WalkedFile::Duplicate(dupe) => { - if input.warn_each_duplicate { - warn!( - "Duplicate file found: {} (original at {})", - dupe.path, dupe.original_path - ); - } - - if args.dry_run { - continue; + walk( + State { + args: args.clone(), + existing_lockfile, + event_tx, + multi_progress, + font_db, + target_backend: { + let params = backend::Params { + api_key: args.api_key, + creator: config.creator.clone(), + expected_price: args.expected_price, + }; + match args.target { + SyncTarget::Cloud => TargetBackend::Cloud(backend::Cloud::new(params).await?), + SyncTarget::Debug => TargetBackend::Debug(backend::Debug::new(params).await?), + SyncTarget::Studio => { + TargetBackend::Studio(backend::Studio::new(params).await?) } - - dupe_count += 1; - - duplicate_assets - .entry(input_name.clone()) - .or_default() - .push(DuplicateFile { - original_path: dupe.original_path, - path: dupe.path, - }); } - } - } + }, + }, + &config, + ) + .await; - if dupe_count > 0 { - warn!("{dupe_count} duplicate files found."); - } + let results = collector_handle.await??; - if args.dry_run { - let new_len = new_assets.len(); + if results.dupe_count > 0 { + warn!("{} duplicate files found", results.dupe_count); + } - if new_len > 0 { - bail!("{new_len} new assets would be synced!") - } else { - info!("No new assets would be synced."); - return Ok(()); - } + if args.dry_run { + if results.new_count > 0 { + bail!("Dry run: {} new assets would be synced", results.new_count) + } else { + info!("Dry run: No new assets would be synced"); + return Ok(()); } - - let processed_assets = - process::process(new_assets, state.clone(), input_name.clone(), input.bleed).await?; - - perform::perform(&processed_assets, state.clone(), input_name.clone()).await?; } - drop(state); - - let (new_lockfile, mut inputs_to_sources) = collector_handle.await??; - if matches!(args.target, SyncTarget::Cloud) { - new_lockfile.write(None).await?; - } - - for (input_name, dupes) in duplicate_assets { - let source = inputs_to_sources.get_mut(&input_name).unwrap(); - - for dupe in dupes { - let original = source - .get(&dupe.original_path) - .expect("We marked a duplicate, but there was no source"); - source.insert(dupe.path, original.clone()); - } + results.new_lockfile.write(None).await?; } - for (input_name, source) in inputs_to_sources { + for (input_name, source) in results.input_sources { let input = config .inputs .get(&input_name) @@ -195,40 +163,212 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> Result<()> { Ok(()) } +struct InputState { + sync_state: Arc, + input_name: String, + input_prefix: PathBuf, + seen_hashes: Arc>, + bleed: bool, +} + +async fn walk(state: State, config: &Config) { + let state = Arc::new(state); + + for (input_name, input) in &config.inputs { + let prefix = input.include.get_prefix(); + let entries = WalkDir::new(&prefix) + .into_iter() + .filter_entry(|entry| prefix == entry.path() || input.include.is_match(entry.path())) + .filter_map(Result::ok) + .filter(|entry| { + entry.file_type().is_file() + && entry + .path() + .extension() + .is_some_and(asset::is_supported_extension) + }) + .collect::>(); + + let ctx = Arc::new(InputState { + sync_state: state.clone(), + input_name: input_name.clone(), + input_prefix: prefix, + seen_hashes: Arc::new(DashMap::with_capacity(entries.len())), + bleed: input.bleed, + }); + + stream::iter(entries.iter()) + .for_each_concurrent(None, |entry| { + eprintln!("Processing entry: {}", entry.path().display()); + let ctx = ctx.clone(); + async move { + if let Err(e) = handle_entry(ctx, entry).await { + debug!("Skipping file {}: {e:?}", entry.path().display()); + } + } + }) + .await; + } +} + +async fn handle_entry(state: Arc, entry: &DirEntry) -> anyhow::Result<()> { + debug!("Handling entry: {}", entry.path().display()); + + let data = fs::read(entry.path()).await?; + let rel_path = entry.path().relative_to(&state.input_prefix)?; + + let mut asset = Asset::new(rel_path.clone(), data) + .await + .context("Failed to create asset")?; + + if let Some(seen_path) = state.seen_hashes.get(&asset.hash) { + let rel_seen_path = seen_path.relative_to(&state.input_prefix)?; + + debug!("Duplicate asset found: {} -> {}", rel_path, rel_seen_path); + + state + .sync_state + .event_tx + .send(Event::Duplicate { + input_name: state.input_name.clone(), + path: rel_path.clone(), + original_path: rel_seen_path, + }) + .await?; + + return Ok(()); + } + + state + .seen_hashes + .insert(asset.hash.clone(), entry.path().into()); + + let lockfile_entry = state + .sync_state + .existing_lockfile + .get(&state.input_name, &asset.hash); + + let needs_sync = lockfile_entry.is_none() + || matches!( + state.sync_state.args.target, + SyncTarget::Debug | SyncTarget::Studio + ); + + if needs_sync { + let font_db = state.sync_state.font_db.clone(); + asset.process(font_db, state.bleed).await?; + + if let Some(asset_ref) = state + .sync_state + .target_backend + .sync(state.sync_state.clone(), state.input_name.clone(), &asset) + .await? + { + state + .sync_state + .event_tx + .send(Event::Insert { + new: matches!(state.sync_state.args.target, SyncTarget::Cloud) + && lockfile_entry.is_none(), + input_name: state.input_name.clone(), + path: asset.path.clone(), + hash: asset.hash.clone(), + asset_ref, + }) + .await? + } + } else if let Some(entry) = lockfile_entry { + state + .sync_state + .event_tx + .send(Event::Insert { + new: false, + input_name: state.input_name.clone(), + path: asset.path.clone(), + hash: asset.hash.clone(), + asset_ref: AssetRef::Cloud(entry.asset_id), + }) + .await? + } + + Ok(()) +} + +struct SyncResults { + new_lockfile: Lockfile, + input_sources: HashMap, + dupe_count: u32, + new_count: u32, +} + async fn collect_events( - mut rx: Receiver, + mut rx: Receiver, config: Config, -) -> Result<(Lockfile, HashMap)> { - let mut lockfile = Lockfile::default(); + dry_run: bool, +) -> anyhow::Result { + let mut new_lockfile = Lockfile::default(); - let mut inputs_to_sources: HashMap = HashMap::new(); + let mut input_sources: HashMap = HashMap::new(); for (input_name, input) in &config.inputs { for (rel_path, web_asset) in &input.web { - inputs_to_sources + input_sources .entry(input_name.clone()) .or_default() .insert(rel_path.clone(), web_asset.clone().into()); } } + let mut new_count = 0; + let mut dupe_count = 0; + while let Some(event) = rx.recv().await { - inputs_to_sources - .entry(event.input_name.clone()) - .or_default() - .insert(event.path, event.asset_ref.clone()); - - if let AssetRef::Cloud(id) = event.asset_ref { - lockfile.insert( - &event.input_name, - &event.hash, - LockfileEntry { asset_id: id }, - ); - } + match event { + Event::Insert { + new, + input_name, + path, + hash, + asset_ref, + } => { + input_sources + .entry(input_name.clone()) + .or_default() + .insert(path, asset_ref.clone()); + + if let AssetRef::Cloud(id) = asset_ref { + new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); + } + + if new { + new_count += 1; - if event.write_lockfile { - lockfile.write(None).await?; + if !dry_run { + new_lockfile.write(None).await?; + } + } + } + Event::Duplicate { + input_name, + path, + original_path, + } => { + dupe_count += 1; + + // If it's a duplicate, then it exists in the map. + let source = input_sources.get_mut(&input_name).unwrap(); + let original = source + .get(&original_path) + .expect("We marked a duplicate, but there was no source"); + + source.insert(path, original.clone()); + } } } - Ok((lockfile, inputs_to_sources)) + Ok(SyncResults { + new_lockfile, + input_sources, + dupe_count, + new_count, + }) } diff --git a/src/sync/perform.rs b/src/sync/perform.rs deleted file mode 100644 index 1754cd3..0000000 --- a/src/sync/perform.rs +++ /dev/null @@ -1,88 +0,0 @@ -use super::{ - SyncState, - backend::{SyncBackend, cloud::CloudBackend, debug::DebugBackend, studio::StudioBackend}, -}; -use crate::{ - asset::Asset, - cli::SyncTarget, - progress_bar::ProgressBar, - sync::{SyncEvent, backend::SyncError}, -}; -use anyhow::bail; -use log::warn; -use std::sync::Arc; - -pub async fn perform( - assets: &Vec, - state: Arc, - input_name: String, -) -> anyhow::Result<()> { - let backend = pick_backend(&state.args.target.clone()).await?; - - let pb = ProgressBar::new( - state.multi_progress.clone(), - &format!("Syncing input \"{input_name}\""), - assets.len(), - ); - - for asset in assets { - let input_name = input_name.clone(); - - let file_name = asset.path.to_string(); - pb.set_msg(&file_name); - - let res = match backend { - TargetBackend::Debug(ref backend) => { - backend.sync(state.clone(), input_name.clone(), asset).await - } - TargetBackend::Cloud(ref backend) => { - backend.sync(state.clone(), input_name.clone(), asset).await - } - TargetBackend::Studio(ref backend) => { - backend.sync(state.clone(), input_name.clone(), asset).await - } - }; - - match res { - Ok(Some(asset_ref)) => { - state - .event_tx - .send(SyncEvent { - path: asset.path.clone(), - asset_ref, - write_lockfile: matches!(backend, TargetBackend::Cloud(_)), - hash: asset.hash.clone(), - input_name, - }) - .await? - } - Ok(None) => {} - Err(SyncError::Fatal(err)) => { - bail!("Failed to sync asset {file_name}: {err:?}"); - } - Err(err) => { - warn!("Failed to sync asset {file_name}: {err:?}"); - } - }; - - pb.inc(1); - } - - pb.finish(); - - Ok(()) -} - -enum TargetBackend { - Debug(DebugBackend), - Cloud(CloudBackend), - Studio(StudioBackend), -} - -async fn pick_backend(target: &SyncTarget) -> anyhow::Result { - match target { - SyncTarget::Debug => Ok(TargetBackend::Debug(DebugBackend::new().await?)), - SyncTarget::Cloud => Ok(TargetBackend::Cloud(CloudBackend::new().await?)), - SyncTarget::Studio => Ok(TargetBackend::Studio(StudioBackend::new().await?)), - } -} diff --git a/src/sync/process.rs b/src/sync/process.rs deleted file mode 100644 index 2f5358d..0000000 --- a/src/sync/process.rs +++ /dev/null @@ -1,37 +0,0 @@ -use super::SyncState; -use crate::{asset::Asset, progress_bar::ProgressBar}; -use log::warn; -use std::sync::Arc; - -pub async fn process( - assets: Vec, - state: Arc, - input_name: String, - bleed: bool, -) -> anyhow::Result> { - let pb = ProgressBar::new( - state.multi_progress.clone(), - &format!("Processing input \"{input_name}\""), - assets.len(), - ); - - let mut processed_assets = Vec::with_capacity(assets.len()); - - for mut asset in assets { - let file_name = asset.path.to_string(); - pb.set_msg(&file_name); - - if let Err(err) = asset.process(state.font_db.clone(), bleed).await { - warn!("Skipping file {file_name} because it failed processing: {err:?}"); - continue; - } - - pb.inc(1); - - processed_assets.push(asset); - } - - pb.finish(); - - Ok(processed_assets) -} diff --git a/src/sync/walk.rs b/src/sync/walk.rs deleted file mode 100644 index e30be76..0000000 --- a/src/sync/walk.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::SyncState; -use crate::{ - asset::Asset, cli::SyncTarget, config::Input, lockfile::LockfileEntry, - progress_bar::ProgressBar, -}; -use anyhow::Context; -use dashmap::DashMap; -use fs_err::tokio as fs; -use futures::stream::{self, StreamExt}; -use log::debug; -use relative_path::{PathExt, RelativePathBuf}; -use std::{path::PathBuf, sync::Arc}; -use tokio::task::spawn_blocking; -use walkdir::WalkDir; - -#[derive(Clone)] -struct WalkCtx { - state: Arc, - input_name: String, - input_prefix: PathBuf, - seen_hashes: Arc>, - pb: ProgressBar, -} - -pub async fn walk( - state: Arc, - input_name: String, - input: &Input, -) -> anyhow::Result> { - let input_prefix = input.path.get_prefix(); - - let entries = WalkDir::new(&input_prefix) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| input.path.is_match(entry.path()) && entry.file_type().is_file()) - .map(|entry| entry.path().to_path_buf()) - .collect::>(); - - let total_files = entries.len(); - let pb = ProgressBar::new( - state.multi_progress.clone(), - &format!("Reading input \"{input_name}\""), - total_files, - ); - - let seen_hashes = Arc::new(DashMap::::with_capacity(total_files)); - - let ctx = WalkCtx { - state, - input_name, - seen_hashes, - pb, - input_prefix, - }; - - let results = stream::iter(entries) - .map(|path| { - let ctx = ctx.clone(); - - async move { - let result = walk_file(&ctx, path.clone()).await; - - ctx.pb.inc(1); - - match result { - Ok(res) => Some(res), - Err(err) => { - debug!("Skipping file {}: {:?}", path.display(), err); - None - } - } - } - }) - .buffer_unordered(100) - .filter_map(|result| async move { result }) - .collect::>() - .await; - - ctx.pb.finish(); - - Ok(results) -} - -pub struct ExistingFile { - pub path: RelativePathBuf, - pub hash: String, - pub entry: LockfileEntry, -} - -pub struct DuplicateFile { - pub path: RelativePathBuf, - pub original_path: RelativePathBuf, -} - -pub enum WalkedFile { - New(Asset), - Existing(ExistingFile), - Duplicate(DuplicateFile), -} - -async fn walk_file(ctx: &WalkCtx, path: PathBuf) -> anyhow::Result { - let data = fs::read(&path).await?; - let rel_path = path.relative_to(&ctx.input_prefix)?; - - let rel_path_clone = rel_path.clone(); - let asset = spawn_blocking(move || Asset::new(rel_path_clone, data)) - .await - .context("Failed to create asset")??; - - if let Some(seen_path) = ctx.seen_hashes.get(&asset.hash) { - let rel_seen_path = seen_path.relative_to(&ctx.input_prefix)?; - - return Ok(WalkedFile::Duplicate(DuplicateFile { - path: rel_path.clone(), - original_path: rel_seen_path, - })); - } - - ctx.seen_hashes.insert(asset.hash.clone(), path.clone()); - - let entry = ctx - .state - .existing_lockfile - .get(&ctx.input_name, &asset.hash); - - match (entry, &ctx.state.args.target) { - (Some(entry), SyncTarget::Cloud) => Ok(WalkedFile::Existing(ExistingFile { - path: rel_path, - hash: asset.hash.clone(), - entry: entry.clone(), - })), - (Some(_), SyncTarget::Studio | SyncTarget::Debug) => Ok(WalkedFile::New(asset)), - (None, _) => Ok(WalkedFile::New(asset)), - } -} diff --git a/src/upload.rs b/src/upload.rs index 98fc561..30424ac 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -1,4 +1,5 @@ use crate::{asset::Asset, cli::UploadArgs, config::Creator, web_api::WebApiClient}; +use anyhow::Context; use fs_err::tokio as fs; use relative_path::PathExt; use resvg::usvg::fontdb::Database; @@ -8,7 +9,7 @@ pub async fn upload(args: UploadArgs) -> anyhow::Result<()> { let path = PathBuf::from(&args.path); let data = fs::read(&path).await?; - let mut asset = Asset::new(path.relative_to(".")?, data)?; + let mut asset = Asset::new(path.relative_to(".")?, data).await?; let mut font_db = Database::new(); font_db.load_system_fonts(); @@ -20,7 +21,12 @@ pub async fn upload(args: UploadArgs) -> anyhow::Result<()> { id: args.creator_id, }; - let client = WebApiClient::new(args.api_key, creator, args.expected_price); + let client = WebApiClient::new( + args.api_key + .context("An API key is required to use the upload command")?, + creator, + args.expected_price, + ); let asset_id = client.upload(&asset).await?; diff --git a/src/web_api.rs b/src/web_api.rs index 2ef658f..11865b7 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -2,7 +2,7 @@ use crate::{ asset::{Asset, AssetType}, config, }; -use anyhow::{Context, anyhow}; +use anyhow::{Context, bail}; use log::{debug, warn}; use reqwest::{ RequestBuilder, Response, StatusCode, @@ -10,57 +10,41 @@ use reqwest::{ multipart, }; use serde::{Deserialize, Serialize}; -use std::{env, time::Duration}; +use std::{ + env, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; const UPLOAD_URL: &str = "https://apis.roblox.com/assets/v1/assets"; const OPERATION_URL: &str = "https://apis.roblox.com/assets/v1/operations"; const ASSET_DESCRIPTION: &str = "Uploaded by Asphalt"; const MAX_DISPLAY_NAME_LENGTH: usize = 50; -#[derive(Debug, thiserror::Error)] -pub enum UploadError { - #[error("Fatal error: (status: {status}, message: {message}, body: {body})")] - Fatal { - status: StatusCode, - message: String, - body: String, - }, - - #[error(transparent)] - Other(#[from] anyhow::Error), -} - pub struct WebApiClient { inner: reqwest::Client, - api_key: Option, + api_key: String, creator: config::Creator, expected_price: Option, + fatally_failed: AtomicBool, } impl WebApiClient { - pub fn new( - api_key: Option, - creator: config::Creator, - expected_price: Option, - ) -> Self { + pub fn new(api_key: String, creator: config::Creator, expected_price: Option) -> Self { WebApiClient { inner: reqwest::Client::new(), api_key, creator, expected_price, + fatally_failed: AtomicBool::new(false), } } - pub async fn upload(&self, asset: &Asset) -> Result { + pub async fn upload(&self, asset: &Asset) -> anyhow::Result { if env::var("ASPHALT_TEST").is_ok() { return Ok(1337); } - let api_key = self - .api_key - .clone() - .context("An API key is necessary to upload")?; - let file_name = asset.path.file_name().unwrap(); let display_name = trim_display_name(file_name); @@ -75,12 +59,12 @@ impl WebApiClient { }; let len = asset.data.len() as u64; - let req_json = serde_json::to_string(&req).map_err(anyhow::Error::from)?; + let req_json = serde_json::to_string(&req)?; let mime = req.asset_type.file_type().to_owned(); let name = file_name.to_owned(); let res = self - .send_with_retry(|| { + .send_with_retry(|client| { let file_part = multipart::Part::stream_with_length( reqwest::Body::from(asset.data.clone()), len, @@ -93,51 +77,47 @@ impl WebApiClient { .text("request", req_json.clone()) .part("fileContent", file_part); - self.inner + client .post(UPLOAD_URL) - .header("x-api-key", &api_key) + .header("x-api-key", &self.api_key) .multipart(form) }) .await?; - let body = res.text().await.map_err(anyhow::Error::from)?; + let body = res.text().await?; - let operation: Operation = serde_json::from_str(&body).map_err(anyhow::Error::from)?; + let operation: Operation = serde_json::from_str(&body)?; let id = self - .poll_operation(operation.operation_id, &api_key) - .await?; + .poll_operation(operation.operation_id, &self.api_key) + .await + .context("Failed to poll operation")?; Ok(id) } - async fn poll_operation(&self, id: String, api_key: &str) -> Result { + async fn poll_operation(&self, id: String, api_key: &str) -> anyhow::Result { let mut delay = Duration::from_secs(1); const MAX_POLLS: u32 = 10; for attempt in 0..MAX_POLLS { let res = self - .send_with_retry(|| { - self.inner + .send_with_retry(|client| { + client .get(format!("{OPERATION_URL}/{id}")) .header("x-api-key", api_key) }) .await?; - let status = res.status(); - let text = res.text().await.map_err(anyhow::Error::from)?; - - if !status.is_success() { - return Err(anyhow!("Failed to poll operation: {} - {}", status, text).into()); - } + let text = res.text().await?; - let operation: Operation = serde_json::from_str(&text).map_err(anyhow::Error::from)?; + let operation: Operation = serde_json::from_str(&text)?; if operation.done { if let Some(response) = operation.response { - return Ok(response.asset_id.parse().map_err(anyhow::Error::from)?); + return Ok(response.asset_id.parse()?); } else { - return Err(anyhow!("Operation completed but no response provided").into()); + bail!("Operation completed but no response provided"); } } @@ -149,18 +129,22 @@ impl WebApiClient { } } - Err(anyhow!("Operation polling exceeded maximum retries").into()) + bail!("Operation polling exceeded maximum retries") } - async fn send_with_retry(&self, make_req: F) -> Result + async fn send_with_retry(&self, make_req: F) -> anyhow::Result where - F: Fn() -> RequestBuilder, + F: Fn(&reqwest::Client) -> RequestBuilder, { + if self.fatally_failed.load(Ordering::SeqCst) { + bail!("A previous request failed due to a fatal error"); + } + const MAX: u8 = 5; let mut attempt = 0; loop { - let res = make_req().send().await.map_err(anyhow::Error::from)?; + let res = make_req(&self.inner).send().await?; let status = res.status(); match status { @@ -185,14 +169,9 @@ impl WebApiClient { } StatusCode::OK => return Ok(res), _ => { - let body = res.text().await.map_err(anyhow::Error::from)?; - let message = extract_error_message(&body); - - return Err(UploadError::Fatal { - status, - message, - body, - }); + let body = res.text().await?; + self.fatally_failed.store(true, Ordering::SeqCst); + bail!("Request failed with status {status}:\n{body}"); } } } @@ -270,18 +249,3 @@ fn trim_display_name(name: &str) -> String { full_path } } - -#[derive(Deserialize)] -struct ErrorBody { - errors: Vec, -} - -#[derive(Deserialize)] -struct ErrorItem { - message: String, -} - -fn extract_error_message(body: &str) -> String { - let error_body: ErrorBody = serde_json::from_str(body).unwrap(); - error_body.errors[0].message.clone() -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 81ea715..bc5f911 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -41,6 +41,7 @@ impl Project { pub fn run(&self) -> assert_cmd::Command { let mut cmd = cargo_bin_cmd!(); cmd.env("ASPHALT_TEST", "true"); + cmd.env("ASPHALT_API_KEY", "test"); cmd.current_dir(self.dir.path()); cmd } From 69d76263526635d1ee8bf47a652897fa3733f3cb Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Wed, 31 Dec 2025 21:45:50 -0500 Subject: [PATCH 05/10] Split some stuff up, got some concurrency bugs --- Cargo.lock | 55 +--------- Cargo.toml | 1 - src/main.rs | 1 - src/progress_bar.rs | 38 ------- src/sync/collect.rs | 162 ++++++++++++++++++++++++++++ src/sync/mod.rs | 255 +++++--------------------------------------- src/sync/walk.rs | 145 +++++++++++++++++++++++++ 7 files changed, 335 insertions(+), 322 deletions(-) delete mode 100644 src/progress_bar.rs create mode 100644 src/sync/collect.rs create mode 100644 src/sync/walk.rs diff --git a/Cargo.lock b/Cargo.lock index 4fb5c6e..8c91d35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,7 +140,6 @@ dependencies = [ "dotenvy", "env_logger", "fs-err", - "futures", "globset", "image", "indicatif", @@ -831,29 +830,14 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.1.3" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ad492b2cf1d89d568a43508ab24f98501fe03f2f31c01e1d0fe7366a71745d2" +checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" dependencies = [ "autocfg", "tokio", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -861,7 +845,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -870,34 +853,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "futures-sink" version = "0.3.31" @@ -916,16 +871,10 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", - "slab", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fb4716d..e6ccd33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ dashmap = "6.1.0" dotenvy = "0.15.7" env_logger = "0.11.8" fs-err = { version = "3.1.3", features = ["tokio"] } -futures = "0.3.31" globset = { version = "0.4.18", features = ["serde1"] } image = "0.25.8" indicatif = "0.18.1" diff --git a/src/main.rs b/src/main.rs index 5000402..c064753 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,6 @@ mod config; mod glob; mod lockfile; mod migrate_lockfile; -mod progress_bar; mod sync; mod upload; mod util; diff --git a/src/progress_bar.rs b/src/progress_bar.rs deleted file mode 100644 index d9ec0b0..0000000 --- a/src/progress_bar.rs +++ /dev/null @@ -1,38 +0,0 @@ -use indicatif::{MultiProgress, ProgressBar as InnerProgressBar, ProgressStyle}; - -#[derive(Debug, Clone)] -pub struct ProgressBar { - inner: InnerProgressBar, -} - -impl ProgressBar { - pub fn new(mp: MultiProgress, prefix: &str, len: usize) -> Self { - let template = "{prefix:>.bold}\n[{bar:40.cyan/blue}] {pos}/{len}: {msg} ({eta})"; - - let inner = mp.add(InnerProgressBar::new(len as u64)); - - inner.set_style( - ProgressStyle::default_bar() - .template(template) - .unwrap() - .progress_chars("=>"), - ); - inner.set_prefix(prefix.to_string()); - - inner.tick(); - - Self { inner } - } - - pub fn set_msg(&self, msg: impl Into) { - self.inner.set_message(msg.into()); - } - - pub fn inc(&self, delta: u64) { - self.inner.inc(delta); - } - - pub fn finish(&self) { - self.inner.finish_and_clear(); - } -} diff --git a/src/sync/collect.rs b/src/sync/collect.rs new file mode 100644 index 0000000..7e9a44c --- /dev/null +++ b/src/sync/collect.rs @@ -0,0 +1,162 @@ +use indicatif::ProgressBar; +use relative_path::RelativePathBuf; +use std::collections::HashMap; +use tokio::sync::mpsc::Receiver; + +use crate::{ + asset::AssetRef, + config::Config, + lockfile::{Lockfile, LockfileEntry}, + sync::codegen::NodeSource, +}; + +pub struct CollectResults { + pub new_lockfile: Lockfile, + pub input_sources: HashMap, + pub dupe_count: u32, + pub new_count: u32, +} + +pub async fn collect_events( + mut rx: Receiver, + config: Config, + dry_run: bool, + spinner: ProgressBar, +) -> anyhow::Result { + let mut new_lockfile = Lockfile::default(); + + let mut input_sources: HashMap = HashMap::new(); + for (input_name, input) in &config.inputs { + for (rel_path, web_asset) in &input.web { + input_sources + .entry(input_name.clone()) + .or_default() + .insert(rel_path.clone(), web_asset.clone().into()); + } + } + + struct Progress { + spinner: ProgressBar, + new: u32, + noop: u32, + dupes: u32, + } + + impl Progress { + fn msg(&self) -> String { + let mut str = format!("Synced {} files", self.new + self.noop + self.dupes); + + let mut parts = Vec::new(); + + if self.new > 0 { + parts.push(format!("{} new", self.new)); + } + if self.noop > 0 { + parts.push(format!("{} no-op", self.noop)); + } + if self.dupes > 0 { + parts.push(format!("{} duplicates", self.dupes)); + } + + if parts.is_empty() { + return str; + } + + str.push_str(" ("); + str.push_str(&parts.join(", ")); + str.push(')'); + str + } + + fn update(&self) { + self.spinner.set_message(self.msg()); + } + + fn finish(&self) { + self.spinner.finish_with_message(self.msg()); + } + } + + let mut progress = Progress { + spinner, + new: 0, + noop: 0, + dupes: 0, + }; + + struct Duplicate { + input_name: String, + path: RelativePathBuf, + original_path: RelativePathBuf, + } + + let mut duplicates = Vec::::new(); + + while let Some(event) = rx.recv().await { + match event { + super::Event::Process { + new, + input_name, + path, + hash, + asset_ref, + } => { + if let Some(asset_ref) = asset_ref { + input_sources + .entry(input_name.clone()) + .or_default() + .insert(path, asset_ref.clone()); + + if let AssetRef::Cloud(id) = asset_ref { + new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); + } + } + + if new { + progress.new += 1; + + if !dry_run { + new_lockfile.write(None).await?; + } + } else { + progress.noop += 1; + } + + progress.update(); + } + super::Event::Duplicate { + input_name, + path, + original_path, + } => { + progress.dupes += 1; + progress.update(); + + duplicates.push(Duplicate { + input_name, + path, + original_path, + }); + } + } + } + + for dupe in duplicates { + // If it's a duplicate, then it exists in the map. + let source = input_sources.get_mut(&dupe.input_name).unwrap(); + let original = source + .get(&dupe.original_path) + .expect("We marked a duplicate, but there was no source"); + + source.insert(dupe.path, original.clone()); + } + + progress.finish(); + + Ok(CollectResults { + new_lockfile, + input_sources, + dupe_count: progress.dupes, + new_count: progress.new, + }) +} diff --git a/src/sync/mod.rs b/src/sync/mod.rs index f7f08af..0835e8d 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,32 +1,28 @@ use crate::{ - asset::{self, Asset, AssetRef}, + asset::{Asset, AssetRef}, cli::{SyncArgs, SyncTarget}, config::Config, - lockfile::{Lockfile, LockfileEntry, RawLockfile}, - sync::{backend::Backend, codegen::NodeSource}, + lockfile::{Lockfile, RawLockfile}, + sync::{backend::Backend, collect::collect_events}, }; use anyhow::{Context, bail}; -use dashmap::DashMap; -use futures::{StreamExt, stream}; -use indicatif::MultiProgress; -use log::{debug, info, warn}; -use relative_path::{PathExt, RelativePathBuf}; +use fs_err::tokio as fs; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use log::{info, warn}; +use relative_path::RelativePathBuf; use resvg::usvg::fontdb; -use std::{collections::HashMap, path::PathBuf, sync::Arc}; -use tokio::{ - fs, - sync::mpsc::{self, Receiver, Sender}, -}; -use walkdir::{DirEntry, WalkDir}; +use std::sync::Arc; +use tokio::sync::mpsc::{self, Sender}; mod backend; mod codegen; +mod collect; +mod walk; pub struct State { args: SyncArgs, existing_lockfile: Lockfile, event_tx: Sender, - multi_progress: MultiProgress, font_db: Arc, target_backend: TargetBackend, } @@ -54,12 +50,12 @@ impl TargetBackend { #[derive(Debug)] enum Event { - Insert { + Process { new: bool, input_name: String, path: RelativePathBuf, hash: String, - asset_ref: AssetRef, + asset_ref: Option, }, Duplicate { input_name: String, @@ -85,17 +81,26 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Resu let (event_tx, event_rx) = mpsc::channel::(100); + let spinner = multi_progress.add(ProgressBar::new_spinner()); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + spinner.set_message("Starting sync..."); + let collector_handle = tokio::spawn({ let config = config.clone(); - async move { collect_events(event_rx, config, args.dry_run).await } + let spinner = spinner.clone(); + async move { collect_events(event_rx, config, args.dry_run, spinner).await } }); - walk( + walk::walk( State { args: args.clone(), existing_lockfile, event_tx, - multi_progress, font_db, target_backend: { let params = backend::Params { @@ -160,215 +165,7 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Resu } } - Ok(()) -} - -struct InputState { - sync_state: Arc, - input_name: String, - input_prefix: PathBuf, - seen_hashes: Arc>, - bleed: bool, -} - -async fn walk(state: State, config: &Config) { - let state = Arc::new(state); - - for (input_name, input) in &config.inputs { - let prefix = input.include.get_prefix(); - let entries = WalkDir::new(&prefix) - .into_iter() - .filter_entry(|entry| prefix == entry.path() || input.include.is_match(entry.path())) - .filter_map(Result::ok) - .filter(|entry| { - entry.file_type().is_file() - && entry - .path() - .extension() - .is_some_and(asset::is_supported_extension) - }) - .collect::>(); - - let ctx = Arc::new(InputState { - sync_state: state.clone(), - input_name: input_name.clone(), - input_prefix: prefix, - seen_hashes: Arc::new(DashMap::with_capacity(entries.len())), - bleed: input.bleed, - }); - - stream::iter(entries.iter()) - .for_each_concurrent(None, |entry| { - eprintln!("Processing entry: {}", entry.path().display()); - let ctx = ctx.clone(); - async move { - if let Err(e) = handle_entry(ctx, entry).await { - debug!("Skipping file {}: {e:?}", entry.path().display()); - } - } - }) - .await; - } -} - -async fn handle_entry(state: Arc, entry: &DirEntry) -> anyhow::Result<()> { - debug!("Handling entry: {}", entry.path().display()); - - let data = fs::read(entry.path()).await?; - let rel_path = entry.path().relative_to(&state.input_prefix)?; - - let mut asset = Asset::new(rel_path.clone(), data) - .await - .context("Failed to create asset")?; - - if let Some(seen_path) = state.seen_hashes.get(&asset.hash) { - let rel_seen_path = seen_path.relative_to(&state.input_prefix)?; - - debug!("Duplicate asset found: {} -> {}", rel_path, rel_seen_path); - - state - .sync_state - .event_tx - .send(Event::Duplicate { - input_name: state.input_name.clone(), - path: rel_path.clone(), - original_path: rel_seen_path, - }) - .await?; - - return Ok(()); - } - - state - .seen_hashes - .insert(asset.hash.clone(), entry.path().into()); - - let lockfile_entry = state - .sync_state - .existing_lockfile - .get(&state.input_name, &asset.hash); - - let needs_sync = lockfile_entry.is_none() - || matches!( - state.sync_state.args.target, - SyncTarget::Debug | SyncTarget::Studio - ); - - if needs_sync { - let font_db = state.sync_state.font_db.clone(); - asset.process(font_db, state.bleed).await?; - - if let Some(asset_ref) = state - .sync_state - .target_backend - .sync(state.sync_state.clone(), state.input_name.clone(), &asset) - .await? - { - state - .sync_state - .event_tx - .send(Event::Insert { - new: matches!(state.sync_state.args.target, SyncTarget::Cloud) - && lockfile_entry.is_none(), - input_name: state.input_name.clone(), - path: asset.path.clone(), - hash: asset.hash.clone(), - asset_ref, - }) - .await? - } - } else if let Some(entry) = lockfile_entry { - state - .sync_state - .event_tx - .send(Event::Insert { - new: false, - input_name: state.input_name.clone(), - path: asset.path.clone(), - hash: asset.hash.clone(), - asset_ref: AssetRef::Cloud(entry.asset_id), - }) - .await? - } + spinner.tick(); Ok(()) } - -struct SyncResults { - new_lockfile: Lockfile, - input_sources: HashMap, - dupe_count: u32, - new_count: u32, -} - -async fn collect_events( - mut rx: Receiver, - config: Config, - dry_run: bool, -) -> anyhow::Result { - let mut new_lockfile = Lockfile::default(); - - let mut input_sources: HashMap = HashMap::new(); - for (input_name, input) in &config.inputs { - for (rel_path, web_asset) in &input.web { - input_sources - .entry(input_name.clone()) - .or_default() - .insert(rel_path.clone(), web_asset.clone().into()); - } - } - - let mut new_count = 0; - let mut dupe_count = 0; - - while let Some(event) = rx.recv().await { - match event { - Event::Insert { - new, - input_name, - path, - hash, - asset_ref, - } => { - input_sources - .entry(input_name.clone()) - .or_default() - .insert(path, asset_ref.clone()); - - if let AssetRef::Cloud(id) = asset_ref { - new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); - } - - if new { - new_count += 1; - - if !dry_run { - new_lockfile.write(None).await?; - } - } - } - Event::Duplicate { - input_name, - path, - original_path, - } => { - dupe_count += 1; - - // If it's a duplicate, then it exists in the map. - let source = input_sources.get_mut(&input_name).unwrap(); - let original = source - .get(&original_path) - .expect("We marked a duplicate, but there was no source"); - - source.insert(path, original.clone()); - } - } - } - - Ok(SyncResults { - new_lockfile, - input_sources, - dupe_count, - new_count, - }) -} diff --git a/src/sync/walk.rs b/src/sync/walk.rs new file mode 100644 index 0000000..7ff3ecb --- /dev/null +++ b/src/sync/walk.rs @@ -0,0 +1,145 @@ +use crate::{ + asset::{self, Asset, AssetRef}, + cli::SyncTarget, + config::Config, +}; +use anyhow::Context; +use dashmap::DashMap; +use fs_err::tokio as fs; +use log::{debug, warn}; +use relative_path::PathExt; +use std::{path::PathBuf, sync::Arc}; +use tokio::task::JoinSet; +use walkdir::WalkDir; + +struct InputState { + sync_state: Arc, + input_name: String, + input_prefix: PathBuf, + seen_hashes: Arc>, + bleed: bool, +} + +pub async fn walk(state: super::State, config: &Config) { + let state = Arc::new(state); + + for (input_name, input) in &config.inputs { + let prefix = input.include.get_prefix(); + let entries = WalkDir::new(&prefix) + .into_iter() + .filter_entry(|entry| prefix == entry.path() || input.include.is_match(entry.path())) + .filter_map(Result::ok) + .filter(|entry| { + entry.file_type().is_file() + && entry + .path() + .extension() + .is_some_and(asset::is_supported_extension) + }) + .collect::>(); + + let ctx = Arc::new(InputState { + sync_state: state.clone(), + input_name: input_name.clone(), + input_prefix: prefix, + seen_hashes: Arc::new(DashMap::with_capacity(entries.len())), + bleed: input.bleed, + }); + + let mut join_set = JoinSet::new(); + + for entry in entries { + let ctx = ctx.clone(); + + join_set.spawn(async move { + if let Err(e) = process_entry(ctx, &entry).await { + warn!("Skipping file {}: {e:?}", entry.path().display()); + } + }); + } + + while join_set.join_next().await.is_some() {} + } +} + +async fn process_entry(state: Arc, entry: &walkdir::DirEntry) -> anyhow::Result<()> { + debug!("Handling entry: {}", entry.path().display()); + + let data = fs::read(entry.path()).await?; + let rel_path = entry.path().relative_to(&state.input_prefix)?; + + let mut asset = Asset::new(rel_path.clone(), data) + .await + .context("Failed to create asset")?; + + if let Some(seen_path) = state.seen_hashes.get(&asset.hash) { + let rel_seen_path = seen_path.relative_to(&state.input_prefix)?; + + debug!("Duplicate asset found: {} -> {}", rel_path, rel_seen_path); + + state + .sync_state + .event_tx + .send(super::Event::Duplicate { + input_name: state.input_name.clone(), + path: rel_path.clone(), + original_path: rel_seen_path, + }) + .await?; + + return Ok(()); + } + + state + .seen_hashes + .insert(asset.hash.clone(), entry.path().into()); + + let lockfile_entry = state + .sync_state + .existing_lockfile + .get(&state.input_name, &asset.hash); + + let needs_sync = lockfile_entry.is_none() + || matches!( + state.sync_state.args.target, + SyncTarget::Debug | SyncTarget::Studio + ); + + if needs_sync { + let font_db = state.sync_state.font_db.clone(); + asset.process(font_db, state.bleed).await?; + + let asset_ref = state + .sync_state + .target_backend + .sync(state.sync_state.clone(), state.input_name.clone(), &asset) + .await?; + + state + .sync_state + .event_tx + .send(super::Event::Process { + new: matches!(state.sync_state.args.target, SyncTarget::Cloud) + && lockfile_entry.is_none(), + input_name: state.input_name.clone(), + path: asset.path.clone(), + hash: asset.hash.clone(), + asset_ref, + }) + .await? + } else if let Some(entry) = lockfile_entry { + state + .sync_state + .event_tx + .send(super::Event::Process { + new: false, + input_name: state.input_name.clone(), + path: asset.path.clone(), + hash: asset.hash.clone(), + asset_ref: Some(AssetRef::Cloud(entry.asset_id)), + }) + .await? + } + + Ok(()) +} From 7ba0eebed3e0c0614f169daabc819af9009f08db Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Fri, 2 Jan 2026 14:39:11 -0500 Subject: [PATCH 06/10] Finish orchestration work: --- Cargo.lock | 23 +--- Cargo.toml | 1 - schema.json | 5 - src/asset.rs | 60 +++++----- src/cli.rs | 33 ++++-- src/config.rs | 2 + src/main.rs | 2 +- src/sync/backend/cloud.rs | 11 +- src/sync/backend/debug.rs | 12 +- src/sync/backend/mod.rs | 7 +- src/sync/backend/studio.rs | 10 +- src/sync/collect.rs | 208 +++++++++++++++++----------------- src/sync/mod.rs | 124 ++++++++------------- src/sync/walk.rs | 221 ++++++++++++++++++++----------------- src/upload.rs | 4 +- tests/sync.rs | 20 +--- 16 files changed, 349 insertions(+), 394 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c91d35..196017b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,7 +136,6 @@ dependencies = [ "bytes", "clap", "clap-verbosity-flag", - "dashmap", "dotenvy", "env_logger", "fs-err", @@ -541,20 +540,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if 1.0.4", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "data-url" version = "0.3.1" @@ -950,12 +935,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.16.0" @@ -1246,7 +1225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e6ccd33..2407886 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ blake3 = "1.8.2" bytes = "1.10.1" clap = { version = "4.5.50", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" -dashmap = "6.1.0" dotenvy = "0.15.7" env_logger = "0.11.8" fs-err = { version = "3.1.3", features = ["tokio"] } diff --git a/schema.json b/schema.json index e0dd6df..b1eb540 100644 --- a/schema.json +++ b/schema.json @@ -113,11 +113,6 @@ "description": "A glob pattern to match files to upload", "type": "string" }, - "warn_each_duplicate": { - "description": "Emit a warning each time a duplicate file is found", - "type": "boolean", - "default": true - }, "web": { "description": "A map of paths relative to the input path to existing assets on Roblox", "type": "object", diff --git a/src/asset.rs b/src/asset.rs index 3887913..a7a0f53 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -1,13 +1,14 @@ use crate::{ config::WebAsset, + lockfile::LockfileEntry, util::{alpha_bleed::alpha_bleed, svg::svg_to_png}, }; -use anyhow::{Context, bail}; +use anyhow::Context; use blake3::Hasher; use bytes::Bytes; use image::DynamicImage; use relative_path::RelativePathBuf; -use resvg::usvg::fontdb::Database; +use resvg::usvg::fontdb::{self}; use serde::Serialize; use std::{ffi::OsStr, fmt, io::Cursor, sync::Arc}; @@ -56,15 +57,19 @@ pub struct Asset { pub path: RelativePathBuf, pub data: Bytes, pub ty: AssetType, - processed: bool, pub ext: String, /// The hash before processing pub hash: String, } impl Asset { - pub async fn new(path: RelativePathBuf, data: Vec) -> anyhow::Result { - let ext = path + pub async fn new( + path: RelativePathBuf, + data: Vec, + font_db: Arc, + bleed: bool, + ) -> anyhow::Result { + let mut ext = path .extension() .context("File has no extension")? .to_string(); @@ -75,44 +80,33 @@ impl Asset { .map(|(_, func)| func(&data)) .context("Unknown file type")??; - let data = Bytes::from(data); + let mut data = Bytes::from(data); let mut hasher = Hasher::new(); hasher.update(&data); let hash = hasher.finalize().to_string(); - Ok(Self { - path, - data, - ty, - processed: false, - ext, - hash, - }) - } - - pub async fn process(&mut self, font_db: Arc, bleed: bool) -> anyhow::Result<()> { - if self.processed { - bail!("Asset has already been processed"); - } - - if self.ext == "svg" { - self.data = svg_to_png(&self.data, font_db.clone()).await?.into(); - self.ext = "png".to_string(); + if ext == "svg" { + data = svg_to_png(&data, font_db.clone()).await?.into(); + ext = "png".to_string(); } - if matches!(self.ty, AssetType::Image(ImageType::Png)) && bleed { - let mut image: DynamicImage = image::load_from_memory(&self.data)?; + if matches!(ty, AssetType::Image(ImageType::Png)) && bleed { + let mut image: DynamicImage = image::load_from_memory(&data)?; alpha_bleed(&mut image); let mut writer = Cursor::new(Vec::new()); image.write_to(&mut writer, image::ImageFormat::Png)?; - self.data = Bytes::from(writer.into_inner()); + data = Bytes::from(writer.into_inner()); } - self.processed = true; - - Ok(()) + Ok(Self { + path, + data, + ty, + ext, + hash, + }) } } @@ -244,3 +238,9 @@ impl From for AssetRef { AssetRef::Cloud(value.id) } } + +impl From<&LockfileEntry> for AssetRef { + fn from(value: &LockfileEntry) -> Self { + AssetRef::Cloud(value.asset_id) + } +} diff --git a/src/cli.rs b/src/cli.rs index d84f105..3f12aaf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,5 @@ use crate::config::CreatorType; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::{Args, Parser, Subcommand}; use clap_verbosity_flag::{InfoLevel, Verbosity}; #[derive(Parser)] @@ -32,13 +32,26 @@ pub enum Commands { GenerateConfigSchema, } -#[derive(ValueEnum, Clone, Copy)] +#[derive(Subcommand, Clone, Copy)] pub enum SyncTarget { - Cloud, + /// Upload assets to Roblox cloud. + Cloud { + /// Error if assets would be uploaded. + #[arg(long)] + dry_run: bool, + }, + /// Write assets to the Roblox Studio content folder. Studio, + /// Write assets to the .asphalt-debug folder. Debug, } +impl SyncTarget { + pub fn write_on_sync(&self) -> bool { + matches!(self, SyncTarget::Cloud { dry_run: false }) + } +} + #[derive(Args, Clone)] pub struct SyncArgs { /// Your Open Cloud API key. @@ -46,18 +59,20 @@ pub struct SyncArgs { pub api_key: Option, /// Where Asphalt should sync assets to. - #[arg(short, long, default_value = "cloud")] - pub target: SyncTarget, - - /// Skip asset syncing and only display what assets will be synced. - #[arg(long)] - pub dry_run: bool, + #[command(subcommand)] + target: Option, /// Provides Roblox with the amount of Robux that you are willing to spend on each non-free asset upload. #[arg(long)] pub expected_price: Option, } +impl SyncArgs { + pub fn target(&self) -> SyncTarget { + self.target.unwrap_or(SyncTarget::Cloud { dry_run: false }) + } +} + #[derive(Args)] pub struct UploadArgs { /// The file to upload. diff --git a/src/config.rs b/src/config.rs index f0b42fc..fe18b8f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,8 @@ pub struct Config { pub inputs: HashMap, } +pub type InputMap = HashMap; + pub const FILE_NAME: &str = "asphalt.toml"; impl Config { diff --git a/src/main.rs b/src/main.rs index c064753..95c6e57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ async fn main() -> anyhow::Result<()> { log::set_max_level(level); match args.command { - Commands::Sync(args) => sync(multi_progress, args).await, + Commands::Sync(args) => sync(args, multi_progress).await, Commands::Upload(args) => upload(args).await, Commands::MigrateLockfile(args) => migrate_lockfile(args).await, Commands::GenerateConfigSchema => generate_config_schema().await, diff --git a/src/sync/backend/cloud.rs b/src/sync/backend/cloud.rs index 50a86af..bc6c0cf 100644 --- a/src/sync/backend/cloud.rs +++ b/src/sync/backend/cloud.rs @@ -1,11 +1,11 @@ use super::Backend; use crate::{ asset::{Asset, AssetRef}, - sync::{State, backend::Params}, + lockfile::LockfileEntry, + sync::backend::Params, web_api::WebApiClient, }; use anyhow::{Context, bail}; -use std::sync::Arc; pub struct Cloud { client: WebApiClient, @@ -29,10 +29,13 @@ impl Backend for Cloud { async fn sync( &self, - _: Arc, - _: String, asset: &Asset, + lockfile_entry: Option<&LockfileEntry>, ) -> anyhow::Result> { + if let Some(lockfile_entry) = lockfile_entry { + return Ok(Some(lockfile_entry.into())); + } + match self.client.upload(asset).await { Ok(id) => Ok(Some(AssetRef::Cloud(id))), Err(err) => bail!("Failed to upload asset: {err:?}"), diff --git a/src/sync/backend/debug.rs b/src/sync/backend/debug.rs index 3b6c121..2b01520 100644 --- a/src/sync/backend/debug.rs +++ b/src/sync/backend/debug.rs @@ -1,12 +1,9 @@ use super::{AssetRef, Backend}; -use crate::{ - asset::Asset, - sync::{State, backend::Params}, -}; +use crate::{asset::Asset, lockfile::LockfileEntry, sync::backend::Params}; use anyhow::Context; use fs_err::tokio as fs; use log::info; -use std::{env, path::PathBuf, sync::Arc}; +use std::{env, path::PathBuf}; pub struct Debug { sync_path: PathBuf, @@ -37,9 +34,8 @@ impl Backend for Debug { async fn sync( &self, - _: Arc, - _: String, asset: &Asset, + lockfile_entry: Option<&LockfileEntry>, ) -> anyhow::Result> { let target_path = asset.path.to_logical_path(&self.sync_path); @@ -53,6 +49,6 @@ impl Backend for Debug { .await .with_context(|| format!("Failed to write asset to {}", target_path.display()))?; - Ok(None) + Ok(lockfile_entry.map(Into::into)) } } diff --git a/src/sync/backend/mod.rs b/src/sync/backend/mod.rs index 6d04a63..4c32d2a 100644 --- a/src/sync/backend/mod.rs +++ b/src/sync/backend/mod.rs @@ -1,9 +1,7 @@ -use std::sync::Arc; - -use super::State; use crate::{ asset::{Asset, AssetRef}, config, + lockfile::LockfileEntry, }; mod cloud; @@ -22,9 +20,8 @@ pub trait Backend { async fn sync( &self, - state: Arc, - input_name: String, asset: &Asset, + lockfile_entry: Option<&LockfileEntry>, ) -> anyhow::Result>; } diff --git a/src/sync/backend/studio.rs b/src/sync/backend/studio.rs index 65f5fc2..aeb24d3 100644 --- a/src/sync/backend/studio.rs +++ b/src/sync/backend/studio.rs @@ -1,14 +1,15 @@ use super::{AssetRef, Backend}; use crate::{ asset::{Asset, AssetType}, - sync::{State, backend::Params}, + lockfile::LockfileEntry, + sync::backend::Params, }; use anyhow::{Context, bail}; use fs_err::tokio as fs; use log::{debug, info, warn}; use relative_path::RelativePathBuf; use roblox_install::RobloxStudio; -use std::{env, path::PathBuf, sync::Arc}; +use std::{env, path::PathBuf}; pub struct Studio { identifier: String, @@ -51,12 +52,11 @@ impl Backend for Studio { async fn sync( &self, - state: Arc, - input_name: String, asset: &Asset, + lockfile_entry: Option<&LockfileEntry>, ) -> anyhow::Result> { if matches!(asset.ty, AssetType::Model(_) | AssetType::Animation) { - return match state.existing_lockfile.get(&input_name, &asset.hash) { + return match lockfile_entry { Some(entry) => Ok(Some(AssetRef::Studio(format!( "rbxassetid://{}", entry.asset_id diff --git a/src/sync/collect.rs b/src/sync/collect.rs index 7e9a44c..3a444e7 100644 --- a/src/sync/collect.rs +++ b/src/sync/collect.rs @@ -1,11 +1,11 @@ -use indicatif::ProgressBar; -use relative_path::RelativePathBuf; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::collections::HashMap; use tokio::sync::mpsc::Receiver; use crate::{ asset::AssetRef, - config::Config, + cli::SyncTarget, + config::InputMap, lockfile::{Lockfile, LockfileEntry}, sync::codegen::NodeSource, }; @@ -13,20 +13,19 @@ use crate::{ pub struct CollectResults { pub new_lockfile: Lockfile, pub input_sources: HashMap, - pub dupe_count: u32, pub new_count: u32, } pub async fn collect_events( mut rx: Receiver, - config: Config, - dry_run: bool, - spinner: ProgressBar, + target: SyncTarget, + inputs: InputMap, + mp: MultiProgress, ) -> anyhow::Result { let mut new_lockfile = Lockfile::default(); let mut input_sources: HashMap = HashMap::new(); - for (input_name, input) in &config.inputs { + for (input_name, input) in inputs { for (rel_path, web_asset) in &input.web { input_sources .entry(input_name.clone()) @@ -35,120 +34,43 @@ pub async fn collect_events( } } - struct Progress { - spinner: ProgressBar, - new: u32, - noop: u32, - dupes: u32, - } - - impl Progress { - fn msg(&self) -> String { - let mut str = format!("Synced {} files", self.new + self.noop + self.dupes); - - let mut parts = Vec::new(); - - if self.new > 0 { - parts.push(format!("{} new", self.new)); - } - if self.noop > 0 { - parts.push(format!("{} no-op", self.noop)); - } - if self.dupes > 0 { - parts.push(format!("{} duplicates", self.dupes)); - } + let mut progress = Progress::new(mp, target); + + while let Some(super::Event { + ty, + input_name, + path, + hash, + asset_ref, + }) = rx.recv().await + { + if let Some(asset_ref) = asset_ref { + input_sources + .entry(input_name.clone()) + .or_default() + .insert(path, asset_ref.clone()); - if parts.is_empty() { - return str; + if let AssetRef::Cloud(id) = asset_ref { + new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); } - - str.push_str(" ("); - str.push_str(&parts.join(", ")); - str.push(')'); - str } - fn update(&self) { - self.spinner.set_message(self.msg()); - } - - fn finish(&self) { - self.spinner.finish_with_message(self.msg()); - } - } - - let mut progress = Progress { - spinner, - new: 0, - noop: 0, - dupes: 0, - }; - - struct Duplicate { - input_name: String, - path: RelativePathBuf, - original_path: RelativePathBuf, - } - - let mut duplicates = Vec::::new(); - - while let Some(event) = rx.recv().await { - match event { - super::Event::Process { - new, - input_name, - path, - hash, - asset_ref, - } => { - if let Some(asset_ref) = asset_ref { - input_sources - .entry(input_name.clone()) - .or_default() - .insert(path, asset_ref.clone()); - - if let AssetRef::Cloud(id) = asset_ref { - new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); - } - } - + match ty { + super::EventType::Synced { new } => { + progress.synced += 1; if new { progress.new += 1; - - if !dry_run { + if target.write_on_sync() { new_lockfile.write(None).await?; } - } else { - progress.noop += 1; } - - progress.update(); } - super::Event::Duplicate { - input_name, - path, - original_path, - } => { + super::EventType::Duplicate => { progress.dupes += 1; - progress.update(); - - duplicates.push(Duplicate { - input_name, - path, - original_path, - }); } } - } - - for dupe in duplicates { - // If it's a duplicate, then it exists in the map. - let source = input_sources.get_mut(&dupe.input_name).unwrap(); - let original = source - .get(&dupe.original_path) - .expect("We marked a duplicate, but there was no source"); - source.insert(dupe.path, original.clone()); + progress.update_msg(); } progress.finish(); @@ -156,7 +78,75 @@ pub async fn collect_events( Ok(CollectResults { new_lockfile, input_sources, - dupe_count: progress.dupes, new_count: progress.new, }) } + +struct Progress { + spinner: ProgressBar, + target: SyncTarget, + synced: u32, + new: u32, + dupes: u32, +} + +impl Progress { + fn new(mp: MultiProgress, target: SyncTarget) -> Self { + let spinner = mp.add(ProgressBar::new_spinner()); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + spinner.set_message("Starting sync..."); + + Self { + spinner, + target, + synced: 0, + new: 0, + dupes: 0, + } + } + + fn get_msg(&self) -> String { + let mut str = format!("Synced {} files", self.synced); + + let mut parts = Vec::new(); + + if self.new > 0 { + let target_msg = match self.target { + SyncTarget::Cloud { dry_run: true } => "uploaded", + SyncTarget::Cloud { dry_run: false } => "checked", + SyncTarget::Studio => "written to content folder", + SyncTarget::Debug => "written to debug folder", + }; + parts.push(format!("{} {}", self.new, target_msg)); + } + let noop = self.synced - self.new; + if noop > 0 { + parts.push(format!("{} no-op", noop)); + } + if self.dupes > 0 { + parts.push(format!("{} duplicates", self.dupes)); + } + + if parts.is_empty() { + return str; + } + + str.push_str(" ("); + str.push_str(&parts.join(", ")); + str.push(')'); + str + } + + fn update_msg(&self) { + self.spinner.set_message(self.get_msg()); + } + + fn finish(&self) { + self.spinner.finish_with_message(self.get_msg()); + } +} diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 0835e8d..9731a51 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -2,31 +2,23 @@ use crate::{ asset::{Asset, AssetRef}, cli::{SyncArgs, SyncTarget}, config::Config, - lockfile::{Lockfile, RawLockfile}, + lockfile::{LockfileEntry, RawLockfile}, sync::{backend::Backend, collect::collect_events}, }; use anyhow::{Context, bail}; use fs_err::tokio as fs; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use log::{info, warn}; +use indicatif::MultiProgress; +use log::info; use relative_path::RelativePathBuf; use resvg::usvg::fontdb; use std::sync::Arc; -use tokio::sync::mpsc::{self, Sender}; +use tokio::sync::mpsc::{self}; mod backend; mod codegen; mod collect; mod walk; -pub struct State { - args: SyncArgs, - existing_lockfile: Lockfile, - event_tx: Sender, - font_db: Arc, - target_backend: TargetBackend, -} - enum TargetBackend { Cloud(backend::Cloud), Debug(backend::Debug), @@ -36,40 +28,35 @@ enum TargetBackend { impl TargetBackend { pub async fn sync( &self, - state: Arc, - input_name: String, asset: &Asset, + lockfile_entry: Option<&LockfileEntry>, ) -> anyhow::Result> { match self { - Self::Cloud(cloud_backend) => cloud_backend.sync(state, input_name, asset).await, - Self::Debug(debug_backend) => debug_backend.sync(state, input_name, asset).await, - Self::Studio(studio_backend) => studio_backend.sync(state, input_name, asset).await, + Self::Cloud(cloud_backend) => cloud_backend.sync(asset, lockfile_entry).await, + Self::Debug(debug_backend) => debug_backend.sync(asset, lockfile_entry).await, + Self::Studio(studio_backend) => studio_backend.sync(asset, lockfile_entry).await, } } } #[derive(Debug)] -enum Event { - Process { - new: bool, - input_name: String, - path: RelativePathBuf, - hash: String, - asset_ref: Option, - }, - Duplicate { - input_name: String, - path: RelativePathBuf, - original_path: RelativePathBuf, - }, +struct Event { + ty: EventType, + input_name: String, + path: RelativePathBuf, + hash: String, + asset_ref: Option, } -pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Result<()> { - if args.dry_run && !matches!(args.target, SyncTarget::Cloud) { - bail!("A dry run doesn't make sense in this context"); - } +#[derive(Debug)] +enum EventType { + Synced { new: bool }, + Duplicate, +} +pub async fn sync(args: SyncArgs, mp: MultiProgress) -> anyhow::Result<()> { let config = Config::read().await?; + let target = args.target(); let existing_lockfile = RawLockfile::read().await?.into_lockfile()?; @@ -81,53 +68,40 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Resu let (event_tx, event_rx) = mpsc::channel::(100); - let spinner = multi_progress.add(ProgressBar::new_spinner()); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.cyan} {msg}") - .unwrap(), - ); - spinner.enable_steady_tick(std::time::Duration::from_millis(100)); - spinner.set_message("Starting sync..."); - let collector_handle = tokio::spawn({ - let config = config.clone(); - let spinner = spinner.clone(); - async move { collect_events(event_rx, config, args.dry_run, spinner).await } + let inputs = config.inputs.clone(); + async move { collect_events(event_rx, target, inputs, mp).await } }); - walk::walk( - State { - args: args.clone(), - existing_lockfile, - event_tx, - font_db, - target_backend: { - let params = backend::Params { - api_key: args.api_key, - creator: config.creator.clone(), - expected_price: args.expected_price, - }; - match args.target { - SyncTarget::Cloud => TargetBackend::Cloud(backend::Cloud::new(params).await?), - SyncTarget::Debug => TargetBackend::Debug(backend::Debug::new(params).await?), - SyncTarget::Studio => { - TargetBackend::Studio(backend::Studio::new(params).await?) - } + let params = walk::Params { + target, + existing_lockfile, + font_db, + backend: { + let params = backend::Params { + api_key: args.api_key, + creator: config.creator.clone(), + expected_price: args.expected_price, + }; + match &target { + SyncTarget::Cloud { dry_run: false } => { + Some(TargetBackend::Cloud(backend::Cloud::new(params).await?)) + } + SyncTarget::Cloud { dry_run: true } => None, + SyncTarget::Debug => Some(TargetBackend::Debug(backend::Debug::new(params).await?)), + SyncTarget::Studio => { + Some(TargetBackend::Studio(backend::Studio::new(params).await?)) } - }, + } }, - &config, - ) - .await; + }; - let results = collector_handle.await??; + walk::walk(params, &config, &event_tx).await; + drop(event_tx); - if results.dupe_count > 0 { - warn!("{} duplicate files found", results.dupe_count); - } + let results = collector_handle.await??; - if args.dry_run { + if matches!(target, SyncTarget::Cloud { dry_run: true }) { if results.new_count > 0 { bail!("Dry run: {} new assets would be synced", results.new_count) } else { @@ -136,7 +110,7 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Resu } } - if matches!(args.target, SyncTarget::Cloud) { + if target.write_on_sync() { results.new_lockfile.write(None).await?; } @@ -165,7 +139,5 @@ pub async fn sync(multi_progress: MultiProgress, args: SyncArgs) -> anyhow::Resu } } - spinner.tick(); - Ok(()) } diff --git a/src/sync/walk.rs b/src/sync/walk.rs index 7ff3ecb..dd00eaa 100644 --- a/src/sync/walk.rs +++ b/src/sync/walk.rs @@ -1,59 +1,87 @@ use crate::{ - asset::{self, Asset, AssetRef}, + asset::{self, Asset}, cli::SyncTarget, config::Config, + lockfile::Lockfile, + sync::TargetBackend, }; use anyhow::Context; -use dashmap::DashMap; use fs_err::tokio as fs; use log::{debug, warn}; use relative_path::PathExt; -use std::{path::PathBuf, sync::Arc}; -use tokio::task::JoinSet; +use resvg::usvg::fontdb; +use std::{ + collections::{ + HashMap, + hash_map::{self}, + }, + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::{ + sync::{Mutex, Semaphore, mpsc::Sender}, + task::JoinSet, +}; use walkdir::WalkDir; +pub struct Params { + pub target: SyncTarget, + pub existing_lockfile: Lockfile, + pub font_db: Arc, + pub backend: Option, +} + struct InputState { - sync_state: Arc, + params: Arc, input_name: String, input_prefix: PathBuf, - seen_hashes: Arc>, + seen_hashes: Arc>>, bleed: bool, } -pub async fn walk(state: super::State, config: &Config) { - let state = Arc::new(state); +pub async fn walk(params: Params, config: &Config, event_tx: &Sender) { + let params = Arc::new(params); for (input_name, input) in &config.inputs { - let prefix = input.include.get_prefix(); - let entries = WalkDir::new(&prefix) - .into_iter() - .filter_entry(|entry| prefix == entry.path() || input.include.is_match(entry.path())) - .filter_map(Result::ok) - .filter(|entry| { - entry.file_type().is_file() - && entry - .path() - .extension() - .is_some_and(asset::is_supported_extension) - }) - .collect::>(); - - let ctx = Arc::new(InputState { - sync_state: state.clone(), + let state = Arc::new(InputState { + params: params.clone(), input_name: input_name.clone(), - input_prefix: prefix, - seen_hashes: Arc::new(DashMap::with_capacity(entries.len())), + input_prefix: input.include.get_prefix(), + seen_hashes: Arc::new(Mutex::new(HashMap::new())), bleed: input.bleed, }); let mut join_set = JoinSet::new(); + let semaphore = Arc::new(Semaphore::new(50)); - for entry in entries { - let ctx = ctx.clone(); + for entry in WalkDir::new(input.include.get_prefix()) + .into_iter() + .filter_entry(|entry| { + let path = entry.path(); + path == input.include.get_prefix() || input.include.is_match(path) + }) + { + let Ok(entry) = entry else { continue }; + + let path = entry.into_path(); + if !path.is_file() { + continue; + } + let Some(ext) = path.extension() else { + continue; + }; + if !asset::is_supported_extension(ext) { + continue; + } + + let ctx = state.clone(); + let semaphore = semaphore.clone(); + let event_tx = event_tx.clone(); join_set.spawn(async move { - if let Err(e) = process_entry(ctx, &entry).await { - warn!("Skipping file {}: {e:?}", entry.path().display()); + let _permit = semaphore.acquire_owned().await.unwrap(); + if let Err(e) = process_entry(ctx.clone(), &path, &event_tx).await { + warn!("Skipping file {}: {e:?}", path.display()); } }); } @@ -62,84 +90,73 @@ pub async fn walk(state: super::State, config: &Config) { } } -async fn process_entry(state: Arc, entry: &walkdir::DirEntry) -> anyhow::Result<()> { - debug!("Handling entry: {}", entry.path().display()); - - let data = fs::read(entry.path()).await?; - let rel_path = entry.path().relative_to(&state.input_prefix)?; - - let mut asset = Asset::new(rel_path.clone(), data) - .await - .context("Failed to create asset")?; - - if let Some(seen_path) = state.seen_hashes.get(&asset.hash) { - let rel_seen_path = seen_path.relative_to(&state.input_prefix)?; - - debug!("Duplicate asset found: {} -> {}", rel_path, rel_seen_path); - - state - .sync_state - .event_tx - .send(super::Event::Duplicate { - input_name: state.input_name.clone(), - path: rel_path.clone(), - original_path: rel_seen_path, - }) - .await?; - - return Ok(()); - } - - state - .seen_hashes - .insert(asset.hash.clone(), entry.path().into()); +async fn process_entry( + state: Arc, + path: &Path, + tx: &Sender, +) -> anyhow::Result<()> { + debug!("Handling entry: {}", path.display()); + + let data = fs::read(path).await?; + let rel_path = path.relative_to(&state.input_prefix)?; + + let asset = Asset::new( + rel_path.clone(), + data, + state.params.font_db.clone(), + state.bleed, + ) + .await + .context("Failed to create asset")?; let lockfile_entry = state - .sync_state + .params .existing_lockfile .get(&state.input_name, &asset.hash); - let needs_sync = lockfile_entry.is_none() - || matches!( - state.sync_state.args.target, - SyncTarget::Debug | SyncTarget::Studio - ); - - if needs_sync { - let font_db = state.sync_state.font_db.clone(); - asset.process(font_db, state.bleed).await?; - - let asset_ref = state - .sync_state - .target_backend - .sync(state.sync_state.clone(), state.input_name.clone(), &asset) - .await?; - - state - .sync_state - .event_tx - .send(super::Event::Process { - new: matches!(state.sync_state.args.target, SyncTarget::Cloud) - && lockfile_entry.is_none(), - input_name: state.input_name.clone(), - path: asset.path.clone(), - hash: asset.hash.clone(), - asset_ref, - }) - .await? - } else if let Some(entry) = lockfile_entry { - state - .sync_state - .event_tx - .send(super::Event::Process { - new: false, - input_name: state.input_name.clone(), - path: asset.path.clone(), - hash: asset.hash.clone(), - asset_ref: Some(AssetRef::Cloud(entry.asset_id)), - }) - .await? + { + let mut seen_hashes = state.seen_hashes.lock().await; + + match seen_hashes.entry(asset.hash.clone()) { + hash_map::Entry::Occupied(entry) => { + let seen_path = entry.get(); + let rel_seen_path = seen_path.relative_to(&state.input_prefix)?; + + debug!("Duplicate asset found: {} -> {}", rel_path, rel_seen_path); + + let event = super::Event { + ty: super::EventType::Duplicate, + input_name: state.input_name.clone(), + path: rel_path.clone(), + asset_ref: lockfile_entry.map(Into::into), + hash: asset.hash.clone(), + }; + tx.send(event).await.unwrap(); + + return Ok(()); + } + hash_map::Entry::Vacant(_) => { + seen_hashes.insert(asset.hash.clone(), path.into()); + } + } } + let always_target = matches!(state.params.target, SyncTarget::Studio | SyncTarget::Debug); + let is_new = always_target || lockfile_entry.is_none(); + + let asset_ref = match state.params.backend { + Some(ref backend) => backend.sync(&asset, lockfile_entry).await?, + None => lockfile_entry.map(Into::into), + }; + + let event = super::Event { + ty: super::EventType::Synced { new: is_new }, + input_name: state.input_name.clone(), + path: asset.path.clone(), + hash: asset.hash.clone(), + asset_ref, + }; + tx.send(event).await.unwrap(); + Ok(()) } diff --git a/src/upload.rs b/src/upload.rs index 30424ac..3f12f4d 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -9,12 +9,10 @@ pub async fn upload(args: UploadArgs) -> anyhow::Result<()> { let path = PathBuf::from(&args.path); let data = fs::read(&path).await?; - let mut asset = Asset::new(path.relative_to(".")?, data).await?; - let mut font_db = Database::new(); font_db.load_system_fonts(); - asset.process(Arc::new(font_db), args.bleed).await?; + let asset = Asset::new(path.relative_to(".")?, data, Arc::new(font_db), args.bleed).await?; let creator = Creator { ty: args.creator_type, diff --git a/tests/sync.rs b/tests/sync.rs index 25a5066..b0e454f 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -44,11 +44,7 @@ fn debug_creates_output() { }); let test_file = project.add_file("test1.png"); - project - .run() - .args(["sync", "--target", "debug"]) - .assert() - .success(); + project.run().args(["sync", "debug"]).assert().success(); project .dir @@ -72,11 +68,7 @@ fn debug_web_assets() { "existing.png" = { id = 1234 } }); - project - .run() - .args(["sync", "--target", "debug"]) - .assert() - .success(); + project.run().args(["sync", "debug"]).assert().success(); project .dir @@ -142,7 +134,7 @@ fn dry_run_none() { project .run() - .args(["sync", "--dry-run"]) + .args(["sync", "cloud", "--dry-run"]) .assert() .success() .stderr(contains("No new assets")); @@ -164,7 +156,7 @@ fn dry_run_1_new() { project .run() - .args(["sync", "--dry-run"]) + .args(["sync", "cloud", "--dry-run"]) .assert() .failure() .stderr(contains("1 new assets")); @@ -208,7 +200,7 @@ fn dry_run_1_new_1_old() { project .run() - .args(["sync", "--dry-run"]) + .args(["sync", "cloud", "--dry-run"]) .assert() .failure() .stderr(contains("1 new assets")); @@ -257,7 +249,7 @@ fn dry_run_2_old() { project .run() - .args(["sync", "--dry-run"]) + .args(["sync", "cloud", "--dry-run"]) .assert() .success() .stderr(contains("No new assets")); From 7d305d3bcf6d18d7c5d46ba936e6274c04e272d0 Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Fri, 2 Jan 2026 15:16:37 -0500 Subject: [PATCH 07/10] Better retry logic, log in-flight and failed assets --- src/sync/collect.rs | 98 +++++++++++++++++++++++++++++++-------------- src/sync/mod.rs | 21 ++++++---- src/sync/walk.rs | 31 ++++++++------ src/web_api.rs | 42 ++++++++++++++----- 4 files changed, 131 insertions(+), 61 deletions(-) diff --git a/src/sync/collect.rs b/src/sync/collect.rs index 3a444e7..d253c9f 100644 --- a/src/sync/collect.rs +++ b/src/sync/collect.rs @@ -1,5 +1,8 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use std::collections::HashMap; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; use tokio::sync::mpsc::Receiver; use crate::{ @@ -36,37 +39,56 @@ pub async fn collect_events( let mut progress = Progress::new(mp, target); - while let Some(super::Event { - ty, - input_name, - path, - hash, - asset_ref, - }) = rx.recv().await - { - if let Some(asset_ref) = asset_ref { - input_sources - .entry(input_name.clone()) - .or_default() - .insert(path, asset_ref.clone()); - - if let AssetRef::Cloud(id) = asset_ref { - new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); - } - } + let mut seen_paths = HashSet::new(); + + while let Some(event) = rx.recv().await { + match event { + super::Event::Finished { + state, + input_name, + path, + rel_path, + hash, + asset_ref, + } => { + seen_paths.insert(path.clone()); + + if let Some(asset_ref) = asset_ref { + input_sources + .entry(input_name.clone()) + .or_default() + .insert(rel_path.clone(), asset_ref.clone()); + + if let AssetRef::Cloud(id) = asset_ref { + new_lockfile.insert(&input_name, &hash, LockfileEntry { asset_id: id }); + } + } - match ty { - super::EventType::Synced { new } => { - progress.synced += 1; - if new { - progress.new += 1; - if target.write_on_sync() { - new_lockfile.write(None).await?; + match state { + super::EventState::Synced { new } => { + progress.synced += 1; + if new { + progress.new += 1; + if target.write_on_sync() { + new_lockfile.write(None).await?; + } + } } + super::EventState::Duplicate => { + progress.dupes += 1; + } + } + + progress.in_flight.remove(&path); + } + super::Event::InFlight(path) => { + if !seen_paths.contains(&path) { + progress.in_flight.insert(path.clone()); } } - super::EventType::Duplicate => { - progress.dupes += 1; + super::Event::Failed(path) => { + progress.failed += 1; + progress.in_flight.remove(&path); } } @@ -85,9 +107,11 @@ pub async fn collect_events( struct Progress { spinner: ProgressBar, target: SyncTarget, + in_flight: HashSet, synced: u32, new: u32, dupes: u32, + failed: u32, } impl Progress { @@ -104,21 +128,23 @@ impl Progress { Self { spinner, target, + in_flight: HashSet::new(), synced: 0, new: 0, dupes: 0, + failed: 0, } } fn get_msg(&self) -> String { - let mut str = format!("Synced {} files", self.synced); + let mut str = format!("Synced {} files", self.synced + self.dupes); let mut parts = Vec::new(); if self.new > 0 { let target_msg = match self.target { - SyncTarget::Cloud { dry_run: true } => "uploaded", - SyncTarget::Cloud { dry_run: false } => "checked", + SyncTarget::Cloud { dry_run: true } => "checked", + SyncTarget::Cloud { dry_run: false } => "uploaded", SyncTarget::Studio => "written to content folder", SyncTarget::Debug => "written to debug folder", }; @@ -132,6 +158,16 @@ impl Progress { parts.push(format!("{} duplicates", self.dupes)); } + let in_flight = self.in_flight.len(); + if in_flight > 0 { + parts.push(format!("{} in-flight", in_flight)); + } + + let failed = self.failed; + if failed > 0 { + parts.push(format!("{} failed", failed)); + } + if parts.is_empty() { return str; } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 9731a51..ad22a84 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -11,7 +11,7 @@ use indicatif::MultiProgress; use log::info; use relative_path::RelativePathBuf; use resvg::usvg::fontdb; -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use tokio::sync::mpsc::{self}; mod backend; @@ -40,16 +40,21 @@ impl TargetBackend { } #[derive(Debug)] -struct Event { - ty: EventType, - input_name: String, - path: RelativePathBuf, - hash: String, - asset_ref: Option, +enum Event { + Finished { + state: EventState, + input_name: String, + path: PathBuf, + rel_path: RelativePathBuf, + hash: String, + asset_ref: Option, + }, + InFlight(PathBuf), + Failed(PathBuf), } #[derive(Debug)] -enum EventType { +enum EventState { Synced { new: bool }, Duplicate, } diff --git a/src/sync/walk.rs b/src/sync/walk.rs index dd00eaa..d605f0a 100644 --- a/src/sync/walk.rs +++ b/src/sync/walk.rs @@ -39,7 +39,7 @@ struct InputState { bleed: bool, } -pub async fn walk(params: Params, config: &Config, event_tx: &Sender) { +pub async fn walk(params: Params, config: &Config, tx: &Sender) { let params = Arc::new(params); for (input_name, input) in &config.inputs { @@ -74,14 +74,18 @@ pub async fn walk(params: Params, config: &Config, event_tx: &Sender anyhow::Result<()> { debug!("Handling entry: {}", path.display()); - let data = fs::read(path).await?; let rel_path = path.relative_to(&state.input_prefix)?; + let data = fs::read(path).await?; + let asset = Asset::new( rel_path.clone(), data, @@ -124,10 +129,11 @@ async fn process_entry( debug!("Duplicate asset found: {} -> {}", rel_path, rel_seen_path); - let event = super::Event { - ty: super::EventType::Duplicate, + let event = super::Event::Finished { + state: super::EventState::Duplicate, input_name: state.input_name.clone(), - path: rel_path.clone(), + path: path.into(), + rel_path: rel_path.clone(), asset_ref: lockfile_entry.map(Into::into), hash: asset.hash.clone(), }; @@ -149,10 +155,11 @@ async fn process_entry( None => lockfile_entry.map(Into::into), }; - let event = super::Event { - ty: super::EventType::Synced { new: is_new }, + let event = super::Event::Finished { + state: super::EventState::Synced { new: is_new }, input_name: state.input_name.clone(), - path: asset.path.clone(), + path: path.into(), + rel_path: asset.path.clone(), hash: asset.hash.clone(), asset_ref, }; diff --git a/src/web_api.rs b/src/web_api.rs index 11865b7..177eb01 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -4,17 +4,17 @@ use crate::{ }; use anyhow::{Context, bail}; use log::{debug, warn}; -use reqwest::{ - RequestBuilder, Response, StatusCode, - header::{self}, - multipart, -}; +use reqwest::{RequestBuilder, Response, StatusCode, multipart}; use serde::{Deserialize, Serialize}; use std::{ env, sync::atomic::{AtomicBool, Ordering}, time::Duration, }; +use tokio::sync::Mutex; +use tokio::time::Instant; + +const RATELIMIT_RESET_HEADER: &str = "x-ratelimit-reset"; const UPLOAD_URL: &str = "https://apis.roblox.com/assets/v1/assets"; const OPERATION_URL: &str = "https://apis.roblox.com/assets/v1/operations"; @@ -27,6 +27,8 @@ pub struct WebApiClient { creator: config::Creator, expected_price: Option, fatally_failed: AtomicBool, + /// Shared rate limit state: when we can next make a request + rate_limit_reset: Mutex>, } impl WebApiClient { @@ -37,6 +39,7 @@ impl WebApiClient { creator, expected_price, fatally_failed: AtomicBool::new(false), + rate_limit_reset: Mutex::new(None), } } @@ -144,6 +147,19 @@ impl WebApiClient { let mut attempt = 0; loop { + { + let reset = self.rate_limit_reset.lock().await; + if let Some(reset_at) = *reset { + let now = Instant::now(); + if reset_at > now { + let wait = reset_at - now; + drop(reset); + debug!("Waiting {:.2}ms for rate limit reset", wait.as_secs_f64()); + tokio::time::sleep(wait).await; + } + } + } + let res = make_req(&self.inner).send().await?; let status = res.status(); @@ -151,20 +167,26 @@ impl WebApiClient { StatusCode::TOO_MANY_REQUESTS if attempt < MAX => { let wait = res .headers() - .get(header::RETRY_AFTER) + .get(RATELIMIT_RESET_HEADER) .and_then(|h| h.to_str().ok()) .and_then(|s| s.parse::().ok()) .map(Duration::from_secs) .unwrap_or_else(|| Duration::from_secs(1 << attempt)); - tokio::time::sleep(wait).await; - attempt += 1; + let reset_at = Instant::now() + wait; + { + let mut reset = self.rate_limit_reset.lock().await; + *reset = Some(reset_at); + } warn!( - "Rate limited, retrying in {} seconds", - wait.as_millis() / 1000 + "Rate limited, retrying in {:.2} seconds", + wait.as_secs_f64() ); + tokio::time::sleep(wait).await; + attempt += 1; + continue; } StatusCode::OK => return Ok(res), From 5f25659d674391a17bc697e2ddfed82fb004a84c Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Fri, 2 Jan 2026 16:09:23 -0500 Subject: [PATCH 08/10] Fancy progress bar! --- src/sync/collect.rs | 82 +++++++++++++++++++++++++-------------------- src/sync/mod.rs | 5 +-- src/sync/walk.rs | 16 +++++---- 3 files changed, 57 insertions(+), 46 deletions(-) diff --git a/src/sync/collect.rs b/src/sync/collect.rs index d253c9f..b02978b 100644 --- a/src/sync/collect.rs +++ b/src/sync/collect.rs @@ -3,7 +3,7 @@ use std::{ collections::{HashMap, HashSet}, path::PathBuf, }; -use tokio::sync::mpsc::Receiver; +use tokio::sync::mpsc::UnboundedReceiver; use crate::{ asset::AssetRef, @@ -16,11 +16,11 @@ use crate::{ pub struct CollectResults { pub new_lockfile: Lockfile, pub input_sources: HashMap, - pub new_count: u32, + pub new_count: u64, } pub async fn collect_events( - mut rx: Receiver, + mut rx: UnboundedReceiver, target: SyncTarget, inputs: InputMap, mp: MultiProgress, @@ -43,6 +43,16 @@ pub async fn collect_events( while let Some(event) = rx.recv().await { match event { + super::Event::Discovered(path) => { + if !seen_paths.contains(&path) { + progress.discovered += 1; + } + } + super::Event::InFlight(path) => { + if !seen_paths.contains(&path) { + progress.in_flight.insert(path.clone()); + } + } super::Event::Finished { state, input_name, @@ -81,18 +91,13 @@ pub async fn collect_events( progress.in_flight.remove(&path); } - super::Event::InFlight(path) => { - if !seen_paths.contains(&path) { - progress.in_flight.insert(path.clone()); - } - } super::Event::Failed(path) => { progress.failed += 1; progress.in_flight.remove(&path); } } - progress.update_msg(); + progress.update(); } progress.finish(); @@ -105,30 +110,39 @@ pub async fn collect_events( } struct Progress { - spinner: ProgressBar, + inner: ProgressBar, target: SyncTarget, in_flight: HashSet, - synced: u32, - new: u32, - dupes: u32, - failed: u32, + discovered: u64, + synced: u64, + new: u64, + dupes: u64, + failed: u64, } impl Progress { + fn get_style(finished: bool) -> ProgressStyle { + ProgressStyle::default_bar() + .template(&format!( + "{{prefix:.{prefix_color}.bold}}{bar} {{pos}}/{{len}} assets: ({{msg}})", + prefix_color = if finished { "green" } else { "cyan" }, + bar = if finished { "" } else { " [{bar:40}]" }, + )) + .unwrap() + .progress_chars("=> ") + } + fn new(mp: MultiProgress, target: SyncTarget) -> Self { let spinner = mp.add(ProgressBar::new_spinner()); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.cyan} {msg}") - .unwrap(), - ); + spinner.set_style(Progress::get_style(false)); + spinner.set_prefix("Syncing"); spinner.enable_steady_tick(std::time::Duration::from_millis(100)); - spinner.set_message("Starting sync..."); Self { - spinner, + inner: spinner, target, in_flight: HashSet::new(), + discovered: 0, synced: 0, new: 0, dupes: 0, @@ -137,16 +151,13 @@ impl Progress { } fn get_msg(&self) -> String { - let mut str = format!("Synced {} files", self.synced + self.dupes); - let mut parts = Vec::new(); if self.new > 0 { let target_msg = match self.target { SyncTarget::Cloud { dry_run: true } => "checked", SyncTarget::Cloud { dry_run: false } => "uploaded", - SyncTarget::Studio => "written to content folder", - SyncTarget::Debug => "written to debug folder", + SyncTarget::Studio | SyncTarget::Debug => "written", }; parts.push(format!("{} {}", self.new, target_msg)); } @@ -160,7 +171,7 @@ impl Progress { let in_flight = self.in_flight.len(); if in_flight > 0 { - parts.push(format!("{} in-flight", in_flight)); + parts.push(format!("{} processing", in_flight)); } let failed = self.failed; @@ -168,21 +179,18 @@ impl Progress { parts.push(format!("{} failed", failed)); } - if parts.is_empty() { - return str; - } - - str.push_str(" ("); - str.push_str(&parts.join(", ")); - str.push(')'); - str + parts.join(", ") } - fn update_msg(&self) { - self.spinner.set_message(self.get_msg()); + fn update(&self) { + self.inner.set_position(self.synced + self.dupes); + self.inner.set_length(self.discovered); + self.inner.set_message(self.get_msg()); } fn finish(&self) { - self.spinner.finish_with_message(self.get_msg()); + self.inner.set_prefix("Synced"); + self.inner.set_style(Progress::get_style(true)); + self.inner.finish(); } } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index ad22a84..9d8b864 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -41,6 +41,8 @@ impl TargetBackend { #[derive(Debug)] enum Event { + Discovered(PathBuf), + InFlight(PathBuf), Finished { state: EventState, input_name: String, @@ -49,7 +51,6 @@ enum Event { hash: String, asset_ref: Option, }, - InFlight(PathBuf), Failed(PathBuf), } @@ -71,7 +72,7 @@ pub async fn sync(args: SyncArgs, mp: MultiProgress) -> anyhow::Result<()> { db }); - let (event_tx, event_rx) = mpsc::channel::(100); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); let collector_handle = tokio::spawn({ let inputs = config.inputs.clone(); diff --git a/src/sync/walk.rs b/src/sync/walk.rs index d605f0a..205700f 100644 --- a/src/sync/walk.rs +++ b/src/sync/walk.rs @@ -19,7 +19,7 @@ use std::{ sync::Arc, }; use tokio::{ - sync::{Mutex, Semaphore, mpsc::Sender}, + sync::{Mutex, Semaphore, mpsc::UnboundedSender}, task::JoinSet, }; use walkdir::WalkDir; @@ -39,7 +39,7 @@ struct InputState { bleed: bool, } -pub async fn walk(params: Params, config: &Config, tx: &Sender) { +pub async fn walk(params: Params, config: &Config, tx: &UnboundedSender) { let params = Arc::new(params); for (input_name, input) in &config.inputs { @@ -78,14 +78,16 @@ pub async fn walk(params: Params, config: &Config, tx: &Sender) { let semaphore = semaphore.clone(); let tx = tx.clone(); + tx.send(super::Event::Discovered(path.clone())).unwrap(); + join_set.spawn(async move { let _permit = semaphore.acquire_owned().await.unwrap(); - tx.send(super::Event::InFlight(path.clone())).await.unwrap(); + tx.send(super::Event::InFlight(path.clone())).unwrap(); if let Err(e) = process_entry(state.clone(), &path, &tx).await { warn!("Failed to process file {}: {e:?}", path.display()); - tx.send(super::Event::Failed(path.clone())).await.unwrap(); + tx.send(super::Event::Failed(path.clone())).unwrap(); } }); } @@ -97,7 +99,7 @@ pub async fn walk(params: Params, config: &Config, tx: &Sender) { async fn process_entry( state: Arc, path: &Path, - tx: &Sender, + tx: &UnboundedSender, ) -> anyhow::Result<()> { debug!("Handling entry: {}", path.display()); @@ -137,7 +139,7 @@ async fn process_entry( asset_ref: lockfile_entry.map(Into::into), hash: asset.hash.clone(), }; - tx.send(event).await.unwrap(); + tx.send(event).unwrap(); return Ok(()); } @@ -163,7 +165,7 @@ async fn process_entry( hash: asset.hash.clone(), asset_ref, }; - tx.send(event).await.unwrap(); + tx.send(event).unwrap(); Ok(()) } From 2e1476c84dcbce8d2d58283953925c2606237acc Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Fri, 2 Jan 2026 16:54:09 -0500 Subject: [PATCH 09/10] Upgrade dependencies --- Cargo.lock | 867 +++++++++++++++++++---------------------------------- Cargo.toml | 27 +- 2 files changed, 322 insertions(+), 572 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 196017b..7e4ebe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if 1.0.4", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -30,6 +30,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + [[package]] name = "aligned-vec" version = "0.6.4" @@ -71,11 +80,11 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -97,9 +106,9 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arg_enum_proc_macro" @@ -124,6 +133,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "asphalt" version = "1.2.0" @@ -157,7 +175,7 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "tokio", - "toml 0.9.8", + "toml", "walkdir", ] @@ -193,9 +211,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.32" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" dependencies = [ "compression-codecs", "compression-core", @@ -216,11 +234,31 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + [[package]] name = "av1-grain" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" dependencies = [ "anyhow", "arrayvec", @@ -277,9 +315,12 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitstream-io" -version = "2.6.0" +version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] [[package]] name = "blake3" @@ -307,15 +348,15 @@ dependencies = [ [[package]] name = "built" -version = "0.7.7" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" @@ -337,15 +378,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.41" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -353,16 +394,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - [[package]] name = "cfg-if" version = "0.1.10" @@ -375,17 +406,11 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "clap" -version = "4.5.50" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -403,9 +428,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.50" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -445,9 +470,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compression-codecs" -version = "0.4.31" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" dependencies = [ "compression-core", "flate2", @@ -456,9 +481,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" [[package]] name = "console" @@ -474,9 +499,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" dependencies = [ "encode_unicode", "libc", @@ -491,6 +516,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -696,9 +730,9 @@ dependencies = [ [[package]] name = "exr" -version = "1.73.0" +version = "1.74.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" dependencies = [ "bit_field", "half", @@ -746,15 +780,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "flate2" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -775,12 +809,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -869,24 +897,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if 1.0.4", - "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.4", - "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", - "wasm-bindgen", + "wasip2", ] [[package]] @@ -899,6 +923,16 @@ dependencies = [ "weezl", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "globset" version = "0.4.18" @@ -926,9 +960,9 @@ dependencies = [ [[package]] name = "half" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e54c115d4f30f52c67202f079c5f9d8b49db4691f460fdb0b4c2e838261b2ba5" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if 1.0.4", "crunchy", @@ -937,9 +971,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -949,12 +983,11 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -989,9 +1022,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1008,28 +1041,11 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64 0.22.1", "bytes", @@ -1051,9 +1067,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -1064,9 +1080,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1077,11 +1093,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1092,42 +1107,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1174,15 +1185,15 @@ dependencies = [ [[package]] name = "image" -version = "0.25.8" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.14.1", "image-webp", "moxcms", "num-traits", @@ -1192,8 +1203,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.0", + "zune-jpeg 0.5.8", ] [[package]] @@ -1220,9 +1231,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown", @@ -1230,11 +1241,11 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.1" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e0ddd45fe8e09ee1a607920b12271f8a5528a41ecaf6e1d1440d6493315b6b" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ - "console 0.16.1", + "console 0.16.2", "portable-atomic", "unicode-width", "unit-prefix", @@ -1253,14 +1264,15 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.2" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "983e3b24350c84ab8a65151f537d67afbbf7153bb9f1110e03e9fa9b07f67a5c" dependencies = [ "console 0.15.11", "once_cell", "serde", "similar", + "tempfile", ] [[package]] @@ -1282,9 +1294,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -1298,18 +1310,18 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" [[package]] name = "jiff" @@ -1341,15 +1353,15 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1379,9 +1391,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libfuzzer-sys" @@ -1417,9 +1429,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" @@ -1432,9 +1444,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loop9" @@ -1445,12 +1457,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lz4_flex" version = "0.11.5" @@ -1501,12 +1507,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1519,20 +1519,20 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "moxcms" -version = "0.7.7" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -1546,12 +1546,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] @@ -1663,6 +1662,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1721,9 +1726,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" [[package]] name = "portable-atomic-util" @@ -1736,9 +1741,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -1812,9 +1817,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.25" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] @@ -1834,66 +1839,11 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.3", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -1960,18 +1910,20 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] name = "rav1e" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" dependencies = [ + "aligned-vec", "arbitrary", "arg_enum_proc_macro", "arrayvec", + "av-scenechange", "av1-grain", "bitstream-io", "built", @@ -1986,23 +1938,21 @@ dependencies = [ "noop_proc_macro", "num-derive", "num-traits", - "once_cell", "paste", "profiling", - "rand 0.8.5", - "rand_chacha 0.3.1", + "rand 0.9.2", + "rand_chacha 0.9.0", "simd_helpers", - "system-deps", - "thiserror 1.0.69", + "thiserror 2.0.17", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" -version = "0.11.20" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" dependencies = [ "avif-serialize", "imgref", @@ -2035,9 +1985,9 @@ dependencies = [ [[package]] name = "rbx_binary" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d419f67c8012bf83569086e1208c541478b3b8e4f523deaa0b80d723fb5ef22" +checksum = "95e2b4a187679aa3d169ed50ed5eedbf26383459fec83bf1232c2934b35b24de" dependencies = [ "ahash", "log", @@ -2053,9 +2003,9 @@ dependencies = [ [[package]] name = "rbx_dom_weak" -version = "4.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74878a4a801afc8014b14ede4b38015a13de5d29ab0095d5ed284a744253f6" +checksum = "a7a5c48c2605913fbb1986bceb3e18ef9f12eadedb7edd62bf9fb03447b57c46" dependencies = [ "ahash", "rbx_types", @@ -2065,9 +2015,9 @@ dependencies = [ [[package]] name = "rbx_reflection" -version = "6.0.0" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "565dd3430991f35443fa6d23cc239fade2110c5089deb6bae5de77c400df4fd2" +checksum = "84f635e79d5d710c82e9049faa57d32945e76a6b041280dc6274f732c0dd78dc" dependencies = [ "rbx_types", "serde", @@ -2076,9 +2026,9 @@ dependencies = [ [[package]] name = "rbx_reflection_database" -version = "2.0.0+roblox-694" +version = "2.0.2+roblox-700" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "844ceb61f23bad59b06d7299b69ff276579316eafa9857981da3012a6223f663" +checksum = "de2753b896d08d74316d8b89fbeb2470ebd3986404ebba82fa85fcc0330955cf" dependencies = [ "dirs 5.0.1", "log", @@ -2089,9 +2039,9 @@ dependencies = [ [[package]] name = "rbx_types" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03220ffce2bd06ad04f77a003cb807f2e5b2a18e97623066a5ac735a978398af" +checksum = "de3b89eefdd71f5e2a25543b1ead9f6ea2ed3fdfba8b397cbdb0de053eb2463e" dependencies = [ "base64 0.13.1", "bitflags 1.3.2", @@ -2104,9 +2054,9 @@ dependencies = [ [[package]] name = "rbx_xml" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be6c302cefe9c92ed09bcbb075cd24379271de135b0af331409a64c2ea3646ee" +checksum = "e0cbaf53b44c9cc0fad1e5dc8ac63fb32fa0ecaa26d32b269cebe4dca8b7b4de" dependencies = [ "ahash", "base64 0.13.1", @@ -2197,11 +2147,10 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" dependencies = [ - "async-compression", "base64 0.22.1", "bytes", "futures-core", @@ -2210,23 +2159,14 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-util", "js-sys", "log", "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", @@ -2234,7 +2174,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -2243,7 +2182,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "gif", + "gif 0.13.3", "image-webp", "log", "pico-args", @@ -2251,7 +2190,7 @@ dependencies = [ "svgtypes", "tiny-skia", "usvg", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -2263,20 +2202,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if 1.0.4", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rmp" version = "0.8.14" @@ -2316,12 +2241,6 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustix" version = "1.1.3" @@ -2335,41 +2254,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -2394,12 +2278,6 @@ dependencies = [ "unicode-script", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "same-file" version = "1.0.6" @@ -2411,9 +2289,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" dependencies = [ "dyn-clone", "ref-cast", @@ -2424,9 +2302,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.0.4" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" dependencies = [ "proc-macro2", "quote", @@ -2483,47 +2361,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "shlex" version = "1.3.0" @@ -2532,18 +2389,18 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simd_helpers" @@ -2575,12 +2432,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - [[package]] name = "slotmap" version = "1.0.7" @@ -2627,12 +2478,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "svgtypes" version = "0.15.3" @@ -2645,9 +2490,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -2674,25 +2519,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 0.8.23", - "version-compare", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "tempfile" version = "3.24.0" @@ -2700,7 +2526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2763,7 +2589,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -2794,9 +2620,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -2845,21 +2671,11 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -2870,26 +2686,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - -[[package]] -name = "toml" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -2897,49 +2701,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "winnow", -] - [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" @@ -2958,17 +2740,22 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -2988,9 +2775,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-core", @@ -2998,9 +2785,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -3052,9 +2839,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-properties" @@ -3082,15 +2869,9 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unit-prefix" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" - -[[package]] -name = "untrusted" -version = "0.9.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "url" @@ -3167,12 +2948,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "version-compare" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" - [[package]] name = "version_check" version = "0.9.5" @@ -3213,15 +2988,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -3233,9 +2999,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -3244,25 +3010,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if 1.0.4", "js-sys", @@ -3273,9 +3025,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3283,31 +3035,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -3323,20 +3075,11 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "weezl" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "winapi" @@ -3384,15 +3127,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -3608,12 +3342,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" -dependencies = [ - "memchr", -] +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] name = "winreg" @@ -3632,15 +3363,15 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xml-rs" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xmlwriter" @@ -3649,12 +3380,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] -name = "yoke" +name = "y4m" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3662,9 +3398,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", @@ -3674,18 +3410,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", @@ -3713,17 +3449,11 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -3732,9 +3462,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -3743,15 +3473,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "zmij" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd" + [[package]] name = "zstd" version = "0.13.3" @@ -3786,6 +3522,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + [[package]] name = "zune-inflate" version = "0.2.54" @@ -3801,5 +3543,14 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +dependencies = [ + "zune-core 0.5.0", ] diff --git a/Cargo.toml b/Cargo.toml index 2407886..af24954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,39 +12,38 @@ license = "MIT" anyhow = "1.0.100" bit-vec = "0.8" blake3 = "1.8.2" -bytes = "1.10.1" -clap = { version = "4.5.50", features = ["derive", "env"] } +bytes = "1.11.0" +clap = { version = "4.5.53", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" dotenvy = "0.15.7" env_logger = "0.11.8" -fs-err = { version = "3.1.3", features = ["tokio"] } +fs-err = { version = "3.2.2", features = ["tokio"] } globset = { version = "0.4.18", features = ["serde1"] } -image = "0.25.8" -indicatif = "0.18.1" +image = "0.25.9" +indicatif = "0.18.3" indicatif-log-bridge = "0.2.3" -log = "0.4.28" -rbx_binary = { version = "2.0.0", features = ["serde"] } -rbx_xml = "2.0.0" +log = "0.4.29" +rbx_binary = { version = "2.0.1", features = ["serde"] } +rbx_xml = "2.0.1" relative-path = { version = "2.0.1", features = ["serde"] } -reqwest = { version = "0.12.24", default-features = false, features = [ +reqwest = { version = "0.13.1", default-features = false, features = [ "gzip", "multipart", - "rustls-tls", ] } resvg = "0.45.1" roblox_install = "1.0.0" -schemars = "1.0.4" +schemars = "1.2.0" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" +serde_json = "1.0.148" thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["full"] } -toml = "0.9.8" +toml = "0.9.10" walkdir = "2.5.0" [dev-dependencies] assert_cmd = "2.1.1" assert_fs = "1.1.3" -insta = { version = "1.43.2", features = ["yaml"] } +insta = { version = "1.45.1", features = ["yaml"] } predicates = "3.1.3" [profile.dev.package] From 4b031e6efb142f305ea4587c15dd3e643aeda00d Mon Sep 17 00:00:00 2001 From: Jack Taylor Date: Fri, 2 Jan 2026 17:07:42 -0500 Subject: [PATCH 10/10] Use spawn_blocking in Asset::new --- src/asset.rs | 49 +++++++++++++++++++++++++++++-------------------- src/util/svg.rs | 2 +- src/web_api.rs | 2 +- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index a7a0f53..4e60b78 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -11,6 +11,7 @@ use relative_path::RelativePathBuf; use resvg::usvg::fontdb::{self}; use serde::Serialize; use std::{ffi::OsStr, fmt, io::Cursor, sync::Arc}; +use tokio::task::spawn_blocking; type AssetCtor = fn(&[u8]) -> anyhow::Result; @@ -80,25 +81,33 @@ impl Asset { .map(|(_, func)| func(&data)) .context("Unknown file type")??; - let mut data = Bytes::from(data); + let (data, hash, ext) = spawn_blocking({ + let font_db = font_db.clone(); + move || { + let mut data = Bytes::from(data); - let mut hasher = Hasher::new(); - hasher.update(&data); - let hash = hasher.finalize().to_string(); + let mut hasher = Hasher::new(); + hasher.update(&data); + let hash = hasher.finalize().to_string(); - if ext == "svg" { - data = svg_to_png(&data, font_db.clone()).await?.into(); - ext = "png".to_string(); - } + if ext == "svg" { + data = svg_to_png(&data, font_db)?.into(); + ext = "png".to_string(); + } - if matches!(ty, AssetType::Image(ImageType::Png)) && bleed { - let mut image: DynamicImage = image::load_from_memory(&data)?; - alpha_bleed(&mut image); + if matches!(ty, AssetType::Image(ImageType::Png)) && bleed { + let mut image: DynamicImage = image::load_from_memory(&data)?; + alpha_bleed(&mut image); - let mut writer = Cursor::new(Vec::new()); - image.write_to(&mut writer, image::ImageFormat::Png)?; - data = Bytes::from(writer.into_inner()); - } + let mut writer = Cursor::new(Vec::new()); + image.write_to(&mut writer, image::ImageFormat::Png)?; + data = Bytes::from(writer.into_inner()); + } + + anyhow::Ok((data, hash, ext)) + } + }) + .await??; Ok(Self { path, @@ -110,7 +119,7 @@ impl Asset { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum AssetType { Model(ModelType), Animation, @@ -166,7 +175,7 @@ impl Serialize for AssetType { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum AudioType { Mp3, Ogg, @@ -174,7 +183,7 @@ pub enum AudioType { Wav, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum ImageType { Png, Jpg, @@ -182,7 +191,7 @@ pub enum ImageType { Tga, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum ModelType { Fbx, GltfJson, @@ -190,7 +199,7 @@ pub enum ModelType { Roblox, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum VideoType { Mp4, Mov, diff --git a/src/util/svg.rs b/src/util/svg.rs index d63fd9e..053a5dc 100644 --- a/src/util/svg.rs +++ b/src/util/svg.rs @@ -4,7 +4,7 @@ use resvg::{ }; use std::sync::Arc; -pub async fn svg_to_png(data: &[u8], fontdb: Arc) -> anyhow::Result> { +pub fn svg_to_png(data: &[u8], fontdb: Arc) -> anyhow::Result> { let opt = Options { fontdb, ..Default::default() diff --git a/src/web_api.rs b/src/web_api.rs index 177eb01..d8c237b 100644 --- a/src/web_api.rs +++ b/src/web_api.rs @@ -53,7 +53,7 @@ impl WebApiClient { let req = Request { display_name, - asset_type: asset.ty.clone(), + asset_type: asset.ty, creation_context: CreationContext { creator: self.creator.clone().into(), expected_price: self.expected_price,