use vta_sdk::protocols::join_requests::manifest::v0_2::{
CommunityBranding, CommunityBrandingAccentColor,
};
use vta_sdk::protocols::vetting::CheckShape;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
pub const BRANDING_STORAGE_KEY: &[u8] = b"community/branding";
#[must_use]
pub fn is_empty(branding: &CommunityBranding) -> bool {
branding.display_name.is_none()
&& branding.accent_color.is_none()
&& branding.logo_url.is_none()
&& branding.ext.is_none()
}
pub async fn load_branding(ks: &KeyspaceHandle) -> Result<CommunityBranding, AppError> {
match ks.get_raw(BRANDING_STORAGE_KEY).await? {
Some(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| AppError::Internal(format!("community branding decode: {e}"))),
None => Ok(CommunityBranding::default()),
}
}
pub async fn store_branding(
ks: &KeyspaceHandle,
branding: &CommunityBranding,
) -> Result<CommunityBranding, AppError> {
branding
.check_shape()
.map_err(|e| AppError::Validation(e.to_string()))?;
let mut stored = branding.clone();
stored.accent_color = stored
.accent_color
.map(|c| CommunityBrandingAccentColor::try_from(c.to_ascii_lowercase()))
.transpose()
.map_err(|e| AppError::Validation(format!("accentColor: {e}")))?;
if is_empty(&stored) {
ks.remove(BRANDING_STORAGE_KEY.to_vec()).await?;
return Ok(stored);
}
let key = String::from_utf8(BRANDING_STORAGE_KEY.to_vec()).expect("key is ASCII");
ks.insert(key, &stored).await?;
Ok(stored)
}
pub fn fields_changed(before: &CommunityBranding, after: &CommunityBranding) -> Vec<String> {
let mut changed = Vec::new();
if before.display_name != after.display_name {
changed.push("displayName".to_string());
}
if before.accent_color != after.accent_color {
changed.push("accentColor".to_string());
}
if before.logo_url != after.logo_url {
changed.push("logoUrl".to_string());
}
if serde_json::to_value(&before.ext).ok() != serde_json::to_value(&after.ext).ok() {
changed.push("ext".to_string());
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn ks() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("community").unwrap();
(dir, store, ks)
}
fn branded_json() -> Value {
json!({
"displayName": "Linux Kernel",
"accentColor": "#1a2b3c",
"logoUrl": "https://kernel.example.org/logo.svg"
})
}
fn branding(value: Value) -> CommunityBranding {
serde_json::from_value(value).unwrap()
}
fn wire(branding: &CommunityBranding) -> Value {
serde_json::to_value(branding).unwrap()
}
#[tokio::test]
async fn branding_round_trips_and_clearing_it_removes_the_row() {
let (_d, _s, ks) = ks().await;
assert!(is_empty(&load_branding(&ks).await.unwrap()));
store_branding(&ks, &branding(branded_json()))
.await
.unwrap();
assert_eq!(wire(&load_branding(&ks).await.unwrap()), branded_json());
store_branding(&ks, &CommunityBranding::default())
.await
.unwrap();
assert!(ks.get_raw(BRANDING_STORAGE_KEY).await.unwrap().is_none());
}
#[tokio::test]
async fn the_accent_color_is_stored_in_lower_case() {
let (_d, _s, ks) = ks().await;
let mut upper = branded_json();
upper["accentColor"] = json!("#1A2B3C");
let stored = store_branding(&ks, &branding(upper)).await.unwrap();
assert_eq!(
stored.accent_color.as_deref().map(String::as_str),
Some("#1a2b3c")
);
assert_eq!(wire(&load_branding(&ks).await.unwrap()), branded_json());
}
#[tokio::test]
async fn out_of_bounds_branding_is_refused_and_not_stored() {
let (_d, _s, ks) = ks().await;
for (member, value) in [
("accentColor", json!("red")),
("accentColor", json!("#12345g")),
("logoUrl", json!("http://kernel.example.org/logo.svg")),
("displayName", json!("x".repeat(129))),
("displayName", json!("")),
("tagline", json!("x")),
] {
let mut bad = branded_json();
bad[member] = value;
if let Ok(parsed) = serde_json::from_value::<CommunityBranding>(bad) {
assert!(
matches!(
store_branding(&ks, &parsed).await,
Err(AppError::Validation(_))
),
"{member}"
);
}
}
assert!(is_empty(&load_branding(&ks).await.unwrap()));
}
#[test]
fn changed_fields_are_named_as_on_the_wire() {
let mut after = branded_json();
after["accentColor"] = json!("#000000");
after["ext"] = json!({ "org.example.console": { "theme": "dark" } });
assert_eq!(
fields_changed(&branding(branded_json()), &branding(after)),
vec!["accentColor", "ext"]
);
}
}