diff --git a/.idea/code.iml b/.idea/code.iml index 3861923c60..a1d7c854b4 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -28,6 +28,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index 0772a3a9c1..89ee75a3b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2223,6 +2223,16 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "component-derive" +version = "0.0.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -5480,6 +5490,7 @@ dependencies = [ "clickhouse", "color-eyre", "color-thief", + "component-derive", "const_format", "dashmap", "derive_more 2.1.1", @@ -5557,16 +5568,6 @@ dependencies = [ "zxcvbn", ] -[[package]] -name = "labrinth-derive" -version = "0.0.0" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "language-tags" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index ab510f52d7..783f73631a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,8 @@ members = [ "apps/labrinth", "packages/app-lib", "packages/ariadne", + "packages/component-derive", "packages/daedalus", - "packages/labrinth-derive", "packages/modrinth-content-management", "packages/modrinth-log", "packages/modrinth-maxmind", @@ -68,6 +68,7 @@ clap = "4.5.48" clickhouse = "0.14.0" color-eyre = "0.6.5" color-thief = "0.2.2" +component-derive = { path = "packages/component-derive" } const_format = "0.2.34" core-foundation = "0.10.1" core-graphics = "0.24.0" diff --git a/apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json b/apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json new file mode 100644 index 0000000000..7aa05f7bab --- /dev/null +++ b/apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT preferences AS \"preferences: Json\"\n FROM user_preferences\n WHERE user_id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "preferences: Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9" +} diff --git a/apps/labrinth/.sqlx/query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json b/apps/labrinth/.sqlx/query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json new file mode 100644 index 0000000000..431861125a --- /dev/null +++ b/apps/labrinth/.sqlx/query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT preferences AS \"preferences: Json\"\n FROM user_preferences\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "preferences: Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa" +} diff --git a/apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json b/apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json new file mode 100644 index 0000000000..d27a0354eb --- /dev/null +++ b/apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_preferences (user_id, preferences)\n VALUES ($1, $2)\n ON CONFLICT (user_id) DO UPDATE\n SET preferences = EXCLUDED.preferences\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index fbfe31b0bf..6c3ab65446 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -40,6 +40,7 @@ clap = { workspace = true, features = ["derive"] } clickhouse = { workspace = true, features = ["time", "uuid"] } color-eyre = { workspace = true } color-thief = { workspace = true } +component-derive = { workspace = true } const_format = { workspace = true } dashmap = { workspace = true } derive_more = { workspace = true, features = ["deref", "deref_mut"] } diff --git a/apps/labrinth/migrations/20260806120000_user-preferences.sql b/apps/labrinth/migrations/20260806120000_user-preferences.sql new file mode 100644 index 0000000000..ed9409d52b --- /dev/null +++ b/apps/labrinth/migrations/20260806120000_user-preferences.sql @@ -0,0 +1,4 @@ +CREATE TABLE user_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferences JSONB NOT NULL +); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 896791e7d3..e647428669 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -38,6 +38,7 @@ pub mod team_item; pub mod thread_item; pub mod user_item; pub mod user_limits; +pub mod user_preferences_item; pub mod user_subscription_item; pub mod users_compliance; pub mod users_notifications_preferences_item; diff --git a/apps/labrinth/src/database/models/user_preferences_item.rs b/apps/labrinth/src/database/models/user_preferences_item.rs new file mode 100644 index 0000000000..853a6397e2 --- /dev/null +++ b/apps/labrinth/src/database/models/user_preferences_item.rs @@ -0,0 +1,75 @@ +use crate::database::Executor; +use crate::database::models::DBUserId; +use crate::models::v3::preferences::PartialUserPreferences; +use sqlx::types::Json; + +pub struct DBUserPreferences; + +impl DBUserPreferences { + pub async fn get<'a, E>( + user_id: DBUserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query!( + r#" + SELECT preferences AS "preferences: Json" + FROM user_preferences + WHERE user_id = $1 + "#, + user_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(row.map(|row| row.preferences.0)) + } + + pub async fn get_for_update<'a, E>( + user_id: DBUserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query!( + r#" + SELECT preferences AS "preferences: Json" + FROM user_preferences + WHERE user_id = $1 + FOR UPDATE + "#, + user_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(row.map(|row| row.preferences.0)) + } + + pub async fn upsert<'a, E>( + user_id: DBUserId, + preferences: &PartialUserPreferences, + exec: E, + ) -> Result<(), sqlx::Error> + where + E: Executor<'a, Database = sqlx::Postgres>, + { + sqlx::query!( + r#" + INSERT INTO user_preferences (user_id, preferences) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE + SET preferences = EXCLUDED.preferences + "#, + user_id.0, + Json(preferences) as Json<&PartialUserPreferences>, + ) + .execute(exec) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 777de99443..be3ce755f7 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -13,6 +13,7 @@ pub mod organizations; pub mod pack; pub mod pats; pub mod payouts; +pub mod preferences; pub mod projects; pub mod reports; pub mod sessions; diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs new file mode 100644 index 0000000000..9afbde2ed8 --- /dev/null +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -0,0 +1,123 @@ +use component_derive::Component; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, Deserialize, ToSchema, Default, Component)] +pub struct UserPreferences { + #[component(nested)] + pub appearance: AppearancePreferences, + #[component(nested)] + pub localization: LocalizationPreferences, + #[component(nested)] + pub layouts: LayoutPreferences, + #[component(nested)] + pub sidebars: SidebarPreferences, + #[component(nested)] + pub social: SocialPreferences, +} + +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] +pub struct AppearancePreferences { + pub auto: bool, + pub theme: Theme, +} + +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, +)] +#[serde(rename_all = "snake_case")] +pub enum Theme { + Light, + #[default] + Dark, + Oled, + Retro, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)] +pub struct LocalizationPreferences { + pub locale: String, +} + +impl Default for LocalizationPreferences { + fn default() -> Self { + Self { + locale: "en-US".to_owned(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)] +pub struct LayoutPreferences { + pub mods: LayoutOption, + pub plugins: LayoutOption, + pub datapacks: LayoutOption, + pub shaders: LayoutOption, + pub resourcepacks: LayoutOption, + pub modpacks: LayoutOption, + pub servers: LayoutOption, + pub users: LayoutOption, +} + +impl Default for LayoutPreferences { + fn default() -> Self { + Self { + mods: LayoutOption::Rows, + plugins: LayoutOption::Rows, + datapacks: LayoutOption::Rows, + shaders: LayoutOption::Grid, + resourcepacks: LayoutOption::Grid, + modpacks: LayoutOption::Rows, + servers: LayoutOption::Rows, + users: LayoutOption::Rows, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum LayoutOption { + Grid, + Rows, +} + +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] +pub struct SidebarPreferences { + pub right_aligned_search: bool, + pub left_aligned_content: bool, +} + +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] +pub struct SocialPreferences { + pub friend_privacy: FriendPrivacy, + pub shared_instances_privacy: InvitePrivacy, + pub hosting_access_privacy: InvitePrivacy, +} + +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, +)] +#[serde(rename_all = "snake_case")] +pub enum FriendPrivacy { + None, + Mutual, + #[default] + Everyone, +} + +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, +)] +#[serde(rename_all = "snake_case")] +pub enum InvitePrivacy { + None, + Friends, + #[default] + Everyone, +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index fd2e087039..1b3d952881 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -217,6 +217,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { users::user_delete_route, users::user_follows_route, users::user_notifications_route, + users::get_user_preferences, + users::edit_user_preferences, version_creation::version_create_route, version_creation::upload_file_to_version_route, version_file::get_version_from_hash_route, diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index ce5215d4ce..840fb9e5fd 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -29,9 +29,13 @@ use crate::{ }; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::UserId; +use eyre::eyre; use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::database::models::user_preferences_item::DBUserPreferences; +use crate::models::v3::preferences::{PartialUserPreferences, UserPreferences}; + pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(user_auth_get_route) .service(users_get_route) @@ -49,6 +53,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(user_delete_route) .service(user_follows_route) .service(user_notifications_route) + .service(get_user_preferences) + .service(edit_user_preferences) .service(get_user_clients); } @@ -367,6 +373,120 @@ pub async fn user_auth_get( Ok(HttpResponse::Ok().json(user)) } +#[utoipa::path(tag = "users", responses((status = OK, body = UserPreferences)))] +#[get("/user/{id}/preferences")] +pub async fn get_user_preferences( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let (_, requester) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_READ, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let target = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; + + let can_access = + requester.id == target.id.into() || requester.role.is_mod(); + if !can_access { + return Err(ApiError::Auth(eyre!( + "you do not have permission to access this user's preferences" + ))); + } + + let preference_overrides = DBUserPreferences::get(target.id, &**pool) + .await + .wrap_internal_err("failed to fetch user preferences")?; + + let preferences = preference_overrides + .map(|overrides| { + let mut preferences = UserPreferences::default(); + overrides.apply_to(&mut preferences); + preferences + }) + .unwrap_or_default(); + + Ok(web::Json(preferences)) +} + +#[utoipa::path( + tag = "users", + request_body = PartialUserPreferences, + responses((status = OK, body = UserPreferences)) +)] +#[patch("/user/{id}/preferences")] +pub async fn edit_user_preferences( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result, ApiError> { + let (_, requester) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let target = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; + + let can_access = + requester.id == target.id.into() || requester.role.is_mod(); + if !can_access { + return Err(ApiError::Auth(eyre!( + "you do not have permission to access this user's preferences" + ))); + } + + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + + let stored = DBUserPreferences::get_for_update(target.id, &mut txn) + .await + .wrap_internal_err("failed to fetch user preferences")?; + + let mut preferences = UserPreferences::default(); + if let Some(stored) = stored { + stored.apply_to(&mut preferences); + } + body.into_inner().apply_to(&mut preferences); + + let overrides = preferences.into_diff_from(&UserPreferences::default()); + DBUserPreferences::upsert(target.id, &overrides, &mut txn) + .await + .wrap_internal_err("failed to update user preferences")?; + + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; + + let mut preferences = UserPreferences::default(); + overrides.apply_to(&mut preferences); + + Ok(web::Json(preferences)) +} + #[derive(Serialize, Deserialize)] pub struct UserIds { pub ids: String, diff --git a/packages/labrinth-derive/Cargo.toml b/packages/component-derive/Cargo.toml similarity index 91% rename from packages/labrinth-derive/Cargo.toml rename to packages/component-derive/Cargo.toml index c3a231868b..5b7b3f6e51 100644 --- a/packages/labrinth-derive/Cargo.toml +++ b/packages/component-derive/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "labrinth-derive" +name = "component-derive" edition.workspace = true rust-version.workspace = true repository.workspace = true diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs new file mode 100644 index 0000000000..17f87fde58 --- /dev/null +++ b/packages/component-derive/src/component.rs @@ -0,0 +1,279 @@ +use darling::{FromDeriveInput, FromField}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Attribute, DeriveInput, Error, Ident, Result, Type, Visibility}; + +#[derive(Debug, FromDeriveInput)] +#[darling(supports(struct_named))] +struct Component { + ident: Ident, + vis: Visibility, + data: darling::ast::Data<(), ComponentField>, +} + +#[derive(Debug, FromField)] +#[darling(attributes(component), forward_attrs)] +struct ComponentField { + ident: Option, + vis: Visibility, + ty: Type, + attrs: Vec, + #[darling(default)] + synthetic: bool, + #[darling(default)] + nested: bool, +} + +pub fn derive(input: &DeriveInput) -> Result { + let Component { ident, vis, data } = Component::from_derive_input(input)?; + let fields = data + .take_struct() + .expect("macro only works on structs with named fields"); + + let fields = &fields.fields; + let struct_serial = struct_serial(&vis, &ident, fields)?; + let struct_partial = struct_partial(&vis, &ident, fields)?; + let impl_apply_to = impl_apply_to(&ident, fields); + let impl_into_diff_from = impl_into_diff_from(&ident, fields); + + // `#[validate(nested)]` needs `Validate` in scope; `as _` avoids a name clash + let validate_import = if fields.iter().any(|field| field.nested) { + quote! { use validator::Validate as _; } + } else { + quote! {} + }; + + Ok(quote! { + #validate_import + + #struct_serial + #struct_partial + + const _: () = { + #impl_apply_to + #impl_into_diff_from + }; + }) +} + +fn struct_serial( + vis: &Visibility, + ident: &Ident, + fields: &[ComponentField], +) -> Result { + let ident_serial = format_ident!("Serial{ident}"); + + let fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = &field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let vis = &field.vis; + let ty = &field.ty; + let attrs = &field.attrs; + + let (field_ty, validate_attr) = if field.nested { + let field_ty = match nested_type(ty, "Serial") { + Ok(field_ty) => field_ty, + Err(err) => return Some(Err(err)), + }; + (field_ty, quote! { #[validate(nested)] }) + } else { + (quote! { #ty }, quote! {}) + }; + + Some(Ok(quote! { + #(#attrs)* + #validate_attr + #vis #ident: #field_ty + })) + }) + .collect::>>()?; + + Ok(quote! { + #[derive( + Debug, + Clone, + ::serde::Serialize, + ::serde::Deserialize, + ::validator::Validate, + ::utoipa::ToSchema, + )] + #vis struct #ident_serial { + #(#fields),* + } + }) +} + +fn struct_partial( + vis: &Visibility, + ident: &Ident, + fields: &[ComponentField], +) -> Result { + let ident_partial = format_ident!("Partial{ident}"); + + let fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = &field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let vis = &field.vis; + let ty = &field.ty; + let attrs = &field.attrs; + + let (inner_ty, validate_attr) = if field.nested { + let inner_ty = match nested_type(ty, "Partial") { + Ok(inner_ty) => inner_ty, + Err(err) => return Some(Err(err)), + }; + (inner_ty, quote! { #[validate(nested)] }) + } else { + (quote! { #ty }, quote! {}) + }; + + let serde_attr = if !field.nested + && let Type::Path(path) = ty + && path + .path + .segments + .first() + .is_some_and(|segment| segment.ident == "Option") + { + quote! { + #[serde( + default, + skip_serializing_if = "::core::option::Option::is_none", + with = "::serde_with::rust::double_option" + )] + } + } else { + quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] } + }; + + Some(Ok(quote! { + #(#attrs)* + #validate_attr + #serde_attr + #vis #ident: ::core::option::Option<#inner_ty> + })) + }) + .collect::>>()?; + + Ok(quote! { + #[derive( + Debug, + Clone, + ::serde::Serialize, + ::serde::Deserialize, + ::validator::Validate, + ::utoipa::ToSchema, + )] + #vis struct #ident_partial { + #(#fields),* + } + }) +} + +fn impl_apply_to(ident: &Ident, fields: &[ComponentField]) -> TokenStream { + let ident_partial = format_ident!("Partial{ident}"); + + let apply_fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let apply_value = if field.nested { + quote! { t.apply_to(&mut component.#ident) } + } else { + quote! { component.#ident = t } + }; + + Some(quote! { + if let Some(t) = self.#ident { + #apply_value; + } + }) + }) + .collect::>(); + + quote! { + impl #ident_partial { + pub fn apply_to(self, component: &mut #ident) { + #(#apply_fields)* + } + } + } +} + +fn impl_into_diff_from( + ident: &Ident, + fields: &[ComponentField], +) -> TokenStream { + let ident_partial = format_ident!("Partial{ident}"); + + let diff_fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let diff_value = if field.nested { + quote! { self.#ident.into_diff_from(&base.#ident) } + } else { + quote! { self.#ident } + }; + + Some(quote! { + #ident: (self.#ident != base.#ident).then(|| #diff_value) + }) + }) + .collect::>(); + + quote! { + impl #ident { + pub fn into_diff_from(self, base: &Self) -> #ident_partial { + #ident_partial { + #(#diff_fields),* + } + } + } + } +} + +fn nested_type(ty: &Type, prefix: &str) -> Result { + if let Type::Path(path) = ty + && let Some(segment) = path.path.segments.last() + { + // FIXME: Validate that nested type also derives component, prob by checking for component impl + let nested = format_ident!("{}{}", prefix, segment.ident); + Ok(quote! { #nested }) + } else { + Err(Error::new_spanned( + ty, + "nested component fields must be a named path type", + )) + } +} diff --git a/packages/labrinth-derive/src/lib.rs b/packages/component-derive/src/lib.rs similarity index 100% rename from packages/labrinth-derive/src/lib.rs rename to packages/component-derive/src/lib.rs diff --git a/packages/labrinth-derive/src/component.rs b/packages/labrinth-derive/src/component.rs deleted file mode 100644 index f76ca8748e..0000000000 --- a/packages/labrinth-derive/src/component.rs +++ /dev/null @@ -1,161 +0,0 @@ -use darling::{FromDeriveInput, FromField}; -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{Attribute, DeriveInput, Ident, Result, Type, Visibility}; - -#[derive(Debug, FromDeriveInput)] -#[darling(supports(struct_named))] -struct Component { - ident: Ident, - vis: Visibility, - data: darling::ast::Data<(), ComponentField>, -} - -#[derive(Debug, FromField)] -#[darling(attributes(component), forward_attrs)] -struct ComponentField { - ident: Option, - vis: Visibility, - ty: Type, - attrs: Vec, - #[darling(default)] - synthetic: bool, -} - -pub fn derive(input: &DeriveInput) -> Result { - let Component { ident, vis, data } = Component::from_derive_input(input)?; - let fields = data - .take_struct() - .expect("macro only works on structs with named fields"); - - let fields = &fields.fields; - let struct_serial = struct_serial(&vis, &ident, fields)?; - let struct_edit = struct_edit(&vis, &ident, fields)?; - - Ok(quote! { - #struct_serial - #struct_edit - }) -} - -fn struct_serial( - vis: &Visibility, - ident: &Ident, - fields: &[ComponentField], -) -> Result { - let ident_serial = format_ident!("{ident}Serial"); - - let fields = fields - .iter() - .filter_map(|field| { - if field.synthetic { - return None; - } - - let ident = &field - .ident - .as_ref() - .expect("macro only works on structs with named fields"); - let vis = &field.vis; - let ty = &field.ty; - let attrs = &field.attrs; - - Some(quote! { - #(#attrs)* - #vis #ident: #ty - }) - }) - .collect::>(); - - Ok(quote! { - #[derive( - Debug, - Clone, - ::serde::Serialize, - ::serde::Deserialize, - ::validator::Validate, - ::utoipa::ToSchema, - )] - #vis struct #ident_serial { - #(#fields),* - } - }) -} - -fn struct_edit( - vis: &Visibility, - ident: &Ident, - fields: &[ComponentField], -) -> Result { - let ident_edit = format_ident!("{ident}Edit"); - - let (fields, apply_fields): (Vec<_>, Vec<_>) = fields - .iter() - .filter_map(|field| { - if field.synthetic { - return None; - } - - let ident = &field - .ident - .as_ref() - .expect("macro only works on structs with named fields"); - let vis = &field.vis; - let ty = &field.ty; - let attrs = &field.attrs; - - let serde_attr = if let Type::Path(path) = ty - && let Some(root_ident) = path.path.segments.first() - && root_ident.ident == "Option" - { - quote! { - #[serde( - default, - skip_serializing_if = "::core::option::Option::is_none", - with = "::serde_with::rust::double_option" - )] - } - } else { - quote! { - #[serde(default)] - } - }; - - Some(( - quote! { - #(#attrs)* - #serde_attr - #vis #ident: ::core::option::Option<#ty> - }, - quote! { - if let Some(t) = self.#ident { - component.#ident = t; - } - }, - )) - }) - .unzip(); - - Ok(quote! { - #[derive( - Debug, - Clone, - ::serde::Serialize, - ::serde::Deserialize, - ::validator::Validate, - ::utoipa::ToSchema, - )] - #vis struct #ident_edit { - #(#fields),* - } - - impl #ident_edit { - pub fn apply_to( - self, - component: &mut #ident, - ) { - #(#apply_fields)* - } - } - }) -}