This commit is contained in:
1 parent
c9a2eeb53d
commit
946a477397
9 files changed
+967
-5
No files matched your search
@@ -0,0 +1,261 @@
|
||||
use crate::event::emit::emit_instance_groups_changed;
|
||||
use crate::state::instance_groups;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH: usize = 256;
|
||||
pub const FAVORITES_GROUP_ID: &str = instance_groups::FAVORITES_GROUP_ID;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroupMembershipUpdate {
|
||||
pub instance_id: String,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn validate_group_name(name: &str) -> crate::Result<&str> {
|
||||
let name = name.trim();
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be empty".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.eq_ignore_ascii_case("none") {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be None".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn normalize_membership_updates(
|
||||
mut updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
|
||||
let mut instance_ids = HashSet::new();
|
||||
for update in &mut updates {
|
||||
if !instance_ids.insert(update.instance_id.clone()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Duplicate instance {} in group membership update",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut group_ids = HashSet::new();
|
||||
update
|
||||
.group_ids
|
||||
.retain(|group_id| group_ids.insert(group_id.clone()));
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
pub async fn list_groups() -> crate::Result<Vec<InstanceGroup>> {
|
||||
Ok(instance_groups::list()
|
||||
.into_iter()
|
||||
.map(|group| InstanceGroup {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_group(name: String) -> crate::Result<InstanceGroup> {
|
||||
let name = validate_group_name(&name)?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
instance_groups::create(&id, name);
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_group_order(group_ids: Vec<String>) -> crate::Result<()> {
|
||||
instance_groups::set_order(&group_ids);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_group_memberships(
|
||||
updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<()> {
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let updates = normalize_membership_updates(updates)?;
|
||||
|
||||
// Note: the instances themselves are deliberately not validated here.
|
||||
// JSON-backed instances (`local:` ids) have no row in the `instances` table,
|
||||
// and requiring one is what used to make grouping them fail outright.
|
||||
let unique_group_ids = updates
|
||||
.iter()
|
||||
.flat_map(|update| update.group_ids.iter())
|
||||
.collect::<HashSet<_>>();
|
||||
for group_id in unique_group_ids {
|
||||
if !instance_groups::group_exists(group_id) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {group_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
instance_groups::set_memberships(
|
||||
&updates
|
||||
.iter()
|
||||
.map(|update| {
|
||||
(update.instance_id.clone(), update.group_ids.clone())
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
let instance_ids = updates
|
||||
.into_iter()
|
||||
.map(|update| update.instance_id)
|
||||
.collect::<Vec<_>>();
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename_group(
|
||||
id: String,
|
||||
new_name: String,
|
||||
) -> crate::Result<InstanceGroup> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be renamed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_name = validate_group_name(&new_name)?;
|
||||
|
||||
let Some(instance_ids) = instance_groups::rename(&id, new_name) else {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
};
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: new_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_group(id: String) -> crate::Result<()> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be deleted".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let Some(instance_ids) = instance_groups::delete(&id) else {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
};
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate,
|
||||
MAX_GROUP_NAME_LENGTH, normalize_membership_updates,
|
||||
validate_group_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_trims_valid_names() {
|
||||
assert_eq!(validate_group_name(" My group ").unwrap(), "My group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_empty_names() {
|
||||
assert!(validate_group_name(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_reserved_name() {
|
||||
assert!(validate_group_name("NoNe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_allows_favorites() {
|
||||
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_long_names() {
|
||||
assert!(
|
||||
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_group_id_is_stable() {
|
||||
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_reject_duplicate_instances() {
|
||||
let updates = vec![
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["first".to_string()],
|
||||
},
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["second".to_string()],
|
||||
},
|
||||
];
|
||||
|
||||
assert!(normalize_membership_updates(updates).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_deduplicate_groups() {
|
||||
let updates =
|
||||
normalize_membership_updates(vec![InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
],
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updates[0].group_ids, &["a", "b"]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod content;
|
||||
mod core_components;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod groups;
|
||||
mod home;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
@@ -72,6 +73,11 @@ pub use self::export_mrpack::{
|
||||
create_mrpack_json, export_mrpack, get_pack_export_candidates,
|
||||
};
|
||||
pub use self::get::{get, get_many, list};
|
||||
pub use self::groups::{
|
||||
FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate,
|
||||
create_group, delete_group, list_groups, rename_group,
|
||||
set_group_memberships, set_group_order,
|
||||
};
|
||||
pub use self::home::{
|
||||
get_daily_playtime, get_daily_playtime_details, set_pinned,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user