use std::fs; use pumpkin_data::translation; use pumpkin_util::PermissionLvl; use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; use pumpkin_util::text::TextComponent; use pumpkin_util::text::color::NamedColor; use tracing::error; use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal}; use crate::command::argument_types::core::string::StringArgumentType; use crate::command::context::command_context::CommandContext; use crate::command::errors::error_types::CommandErrorType; use crate::command::node::dispatcher::CommandDispatcher; use crate::command::node::{CommandExecutor, CommandExecutorResult}; use crate::command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult}; use crate::command::suggestion::suggestions::SuggestionsBuilder; use crate::server::Server; const DESCRIPTION: &str = "Controls loaded data packs."; const PERMISSION: &str = "minecraft:command.datapack"; static ERROR_UNKNOWN_DATAPACK: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DATAPACK_UNKNOWN, translation::java::COMMANDS_DATAPACK_UNKNOWN, ); static ERROR_ENABLE_FAILED: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DATAPACK_ENABLE_FAILED, translation::java::COMMANDS_DATAPACK_ENABLE_FAILED, ); static ERROR_DISABLE_FAILED: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DATAPACK_DISABLE_FAILED, translation::java::COMMANDS_DATAPACK_DISABLE_FAILED, ); static ERROR_CREATE_ALREADY_EXISTS: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DATAPACK_CREATE_ALREADY_EXISTS, translation::java::COMMANDS_DATAPACK_CREATE_ALREADY_EXISTS, ); static ERROR_CREATE_INVALID_NAME: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DATAPACK_CREATE_INVALID_NAME, translation::java::COMMANDS_DATAPACK_CREATE_INVALID_NAME, ); static ERROR_CREATE_IO_FAILURE: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DATAPACK_CREATE_IO_FAILURE, translation::java::COMMANDS_DATAPACK_CREATE_IO_FAILURE, ); fn get_enabled_packs(server: &Server) -> Vec { crate::data::datapack::DatapackManager::get_enabled_packs(server) } fn get_available_packs(server: &Server) -> Vec { crate::data::datapack::DatapackManager::get_available_packs(server) } fn find_pack_name(server: &Server, input: &str) -> Option { crate::data::datapack::DatapackManager::find_pack_name(server, input) } fn format_pack(name: &str) -> TextComponent { TextComponent::text(format!("[{name}]")).color_named(NamedColor::Green) } struct AvailablePackSuggestionProvider; impl SuggestionProvider for AvailablePackSuggestionProvider { fn suggest( &self, context: &CommandContext, mut builder: SuggestionsBuilder, ) -> SuggestionProviderResult { let server = context.server(); for pack in get_available_packs(server) { if pack.contains(' ') || pack.contains('/') { builder = builder.suggest(format!("\"{pack}\"")); } else { builder = builder.suggest(pack.clone()); } if let Some(short) = pack.strip_prefix("file/") { if short.contains(' ') { builder = builder.suggest(format!("\"{short}\"")); } else { builder = builder.suggest(short.to_string()); } } } builder.build() } } struct EnabledPackSuggestionProvider; impl SuggestionProvider for EnabledPackSuggestionProvider { fn suggest( &self, context: &CommandContext, mut builder: SuggestionsBuilder, ) -> SuggestionProviderResult { let server = context.server(); for pack in get_enabled_packs(server) { if pack.contains(' ') || pack.contains('/') { builder = builder.suggest(format!("\"{pack}\"")); } else { builder = builder.suggest(pack.clone()); } if let Some(short) = pack.strip_prefix("file/") { if short.contains(' ') { builder = builder.suggest(format!("\"{short}\"")); } else { builder = builder.suggest(short.to_string()); } } } builder.build() } } #[derive(Clone, Copy)] enum ListFilter { All, Available, Enabled, } struct DatapackListExecutor(ListFilter); impl CommandExecutor for DatapackListExecutor { fn execute(&self, context: &CommandContext) -> CommandExecutorResult { let server = context.server(); let enabled = get_enabled_packs(server); let available = get_available_packs(server); match self.0 { ListFilter::All => { send_enabled_list(context, &enabled); send_available_list(context, &available); Ok(enabled.len() as i32) } ListFilter::Enabled => { send_enabled_list(context, &enabled); Ok(enabled.len() as i32) } ListFilter::Available => { send_available_list(context, &available); Ok(available.len() as i32) } } } } fn send_enabled_list(context: &CommandContext<'_>, enabled: &[String]) { if enabled.is_empty() { context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_LIST_ENABLED_NONE, translation::java::COMMANDS_DATAPACK_LIST_ENABLED_NONE, [], ), false, ); } else { let packs_component = TextComponent::join_with_comma(enabled.iter().map(|p| format_pack(p)).collect()); context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_LIST_ENABLED_SUCCESS, translation::java::COMMANDS_DATAPACK_LIST_ENABLED_SUCCESS, [ TextComponent::text(enabled.len().to_string()), packs_component, ], ), false, ); } } fn send_available_list(context: &CommandContext<'_>, available: &[String]) { if available.is_empty() { context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_NONE, translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_NONE, [], ), false, ); } else { let packs_component = TextComponent::join_with_comma(available.iter().map(|p| format_pack(p)).collect()); context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_SUCCESS, translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_SUCCESS, [ TextComponent::text(available.len().to_string()), packs_component, ], ), false, ); } } #[derive(Clone, Copy)] enum EnablePosition { First, Last, } struct DatapackEnableExecutor(EnablePosition); impl CommandExecutor for DatapackEnableExecutor { fn execute(&self, context: &CommandContext) -> CommandExecutorResult { let name_str = StringArgumentType::get(context, "name")?; let server = context.server(); let Some(resolved_name) = find_pack_name(server, name_str) else { return Err(ERROR_UNKNOWN_DATAPACK .create_without_context(TextComponent::text(name_str.to_string()))); }; let enabled = get_enabled_packs(server); if enabled.contains(&resolved_name) { return Err( ERROR_ENABLE_FAILED.create_without_context(TextComponent::text(resolved_name)) ); } let target = resolved_name.clone(); let pos = self.0; server.level_info.rcu(|level_info| { let mut new_info = (**level_info).clone(); new_info.data_packs.disabled.retain(|p| p != &target); new_info.data_packs.enabled.retain(|p| p != &target); match pos { EnablePosition::First => { new_info.data_packs.enabled.insert(0, target.clone()); } EnablePosition::Last => { new_info.data_packs.enabled.push(target.clone()); } } new_info }); if let Err(err) = server.save_world_info() { error!("Failed to save world info: {err}"); } server.reload_datapacks(server); context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, [format_pack(&resolved_name)], ), true, ); Ok(server.level_info.load().data_packs.enabled.len() as i32) } } #[derive(Clone, Copy)] enum BeforeOrAfter { Before, After, } struct DatapackEnableExistingExecutor(BeforeOrAfter); impl CommandExecutor for DatapackEnableExistingExecutor { fn execute(&self, context: &CommandContext) -> CommandExecutorResult { let name_str = StringArgumentType::get(context, "name")?; let existing_str = StringArgumentType::get(context, "existing")?; let server = context.server(); let Some(target_pack) = find_pack_name(server, name_str) else { return Err(ERROR_UNKNOWN_DATAPACK .create_without_context(TextComponent::text(name_str.to_string()))); }; let Some(existing_pack) = find_pack_name(server, existing_str) else { return Err(ERROR_UNKNOWN_DATAPACK .create_without_context(TextComponent::text(existing_str.to_string()))); }; let enabled = get_enabled_packs(server); if enabled.contains(&target_pack) { return Err( ERROR_ENABLE_FAILED.create_without_context(TextComponent::text(target_pack)) ); } if !enabled.contains(&existing_pack) { return Err( ERROR_DISABLE_FAILED.create_without_context(TextComponent::text(existing_pack)) ); } let target = target_pack.clone(); let existing = existing_pack; let before_or_after = self.0; server.level_info.rcu(|level_info| { let mut new_info = (**level_info).clone(); new_info.data_packs.disabled.retain(|p| p != &target); new_info.data_packs.enabled.retain(|p| p != &target); if let Some(idx) = new_info .data_packs .enabled .iter() .position(|p| p == &existing) { let insert_pos = match before_or_after { BeforeOrAfter::Before => idx, BeforeOrAfter::After => idx + 1, }; new_info .data_packs .enabled .insert(insert_pos, target.clone()); } else { new_info.data_packs.enabled.push(target.clone()); } new_info }); if let Err(err) = server.save_world_info() { error!("Failed to save world info: {err}"); } server.reload_datapacks(server); context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, [format_pack(&target_pack)], ), true, ); Ok(server.level_info.load().data_packs.enabled.len() as i32) } } struct DatapackDisableExecutor; impl CommandExecutor for DatapackDisableExecutor { fn execute(&self, context: &CommandContext) -> CommandExecutorResult { let name_str = StringArgumentType::get(context, "name")?; let server = context.server(); let Some(target_pack) = find_pack_name(server, name_str) else { return Err(ERROR_DISABLE_FAILED .create_without_context(TextComponent::text(name_str.to_string()))); }; let enabled = get_enabled_packs(server); if !enabled.contains(&target_pack) { return Err(ERROR_DISABLE_FAILED .create_without_context(TextComponent::text(name_str.to_string()))); } if target_pack == "vanilla" { return Err(ERROR_DISABLE_FAILED .create_without_context(TextComponent::text("vanilla".to_string()))); } let target = target_pack.clone(); server.level_info.rcu(|level_info| { let mut new_info = (**level_info).clone(); new_info.data_packs.enabled.retain(|p| p != &target); if !new_info.data_packs.disabled.contains(&target) { new_info.data_packs.disabled.push(target.clone()); } new_info }); if let Err(err) = server.save_world_info() { error!("Failed to save world info: {err}"); } server.reload_datapacks(server); context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_MODIFY_DISABLE, translation::java::COMMANDS_DATAPACK_MODIFY_DISABLE, [format_pack(&target_pack)], ), true, ); Ok(server.level_info.load().data_packs.enabled.len() as i32) } } fn is_valid_pack_name(name: &str) -> bool { !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains("..") && name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') } struct DatapackCreateExecutor; impl CommandExecutor for DatapackCreateExecutor { fn execute(&self, context: &CommandContext) -> CommandExecutorResult { let name_str = StringArgumentType::get(context, "name")?; let description = StringArgumentType::get(context, "description").unwrap_or(name_str); if !is_valid_pack_name(name_str) { return Err(ERROR_CREATE_INVALID_NAME .create_without_context(TextComponent::text(name_str.to_string()))); } let server = context.server(); let datapacks_dir = server.basic_config.get_world_path().join("datapacks"); let pack_dir = datapacks_dir.join(name_str); if pack_dir.exists() { return Err(ERROR_CREATE_ALREADY_EXISTS .create_without_context(TextComponent::text(name_str.to_string()))); } let data_dir = pack_dir.join("data"); if let Err(err) = fs::create_dir_all(&data_dir) { error!("Failed to create datapack directory: {err}"); return Err(ERROR_CREATE_IO_FAILURE .create_without_context(TextComponent::text(name_str.to_string()))); } let mcmeta_content = serde_json::json!({ "pack": { "pack_format": 61, "description": description } }); let mcmeta_path = pack_dir.join("pack.mcmeta"); let mcmeta_str = match serde_json::to_string_pretty(&mcmeta_content) { Ok(s) => s, Err(err) => { error!("Failed to serialize pack.mcmeta: {err}"); return Err(ERROR_CREATE_IO_FAILURE .create_without_context(TextComponent::text(name_str.to_string()))); } }; if let Err(err) = fs::write(&mcmeta_path, mcmeta_str) { error!("Failed to write pack.mcmeta: {err}"); return Err(ERROR_CREATE_IO_FAILURE .create_without_context(TextComponent::text(name_str.to_string()))); } context.source.send_feedback( TextComponent::translate_cross( translation::java::COMMANDS_DATAPACK_CREATE_SUCCESS, translation::java::COMMANDS_DATAPACK_CREATE_SUCCESS, [TextComponent::text(name_str.to_string())], ), true, ); Ok(1) } } pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) { registry.register_permission_or_panic(Permission::new( PERMISSION, DESCRIPTION, PermissionDefault::Op(PermissionLvl::Two), )); let list_builder = literal("list") .then(literal("available").executes(DatapackListExecutor(ListFilter::Available))) .then(literal("enabled").executes(DatapackListExecutor(ListFilter::Enabled))) .executes(DatapackListExecutor(ListFilter::All)); let enable_builder = literal("enable").then( argument("name", StringArgumentType::QuotablePhrase) .suggests(AvailablePackSuggestionProvider) .then(literal("first").executes(DatapackEnableExecutor(EnablePosition::First))) .then(literal("last").executes(DatapackEnableExecutor(EnablePosition::Last))) .then( literal("before").then( argument("existing", StringArgumentType::QuotablePhrase) .suggests(EnabledPackSuggestionProvider) .executes(DatapackEnableExistingExecutor(BeforeOrAfter::Before)), ), ) .then( literal("after").then( argument("existing", StringArgumentType::QuotablePhrase) .suggests(EnabledPackSuggestionProvider) .executes(DatapackEnableExistingExecutor(BeforeOrAfter::After)), ), ) .executes(DatapackEnableExecutor(EnablePosition::Last)), ); let disable_builder = literal("disable").then( argument("name", StringArgumentType::QuotablePhrase) .suggests(EnabledPackSuggestionProvider) .executes(DatapackDisableExecutor), ); let create_builder = literal("create").then( argument("name", StringArgumentType::SingleWord) .then( argument("description", StringArgumentType::GreedyPhrase) .executes(DatapackCreateExecutor), ) .executes(DatapackCreateExecutor), ); dispatcher.register( command("datapack", DESCRIPTION) .requires(PERMISSION) .then(list_builder) .then(enable_builder) .then(disable_builder) .then(create_builder), ); } #[cfg(test)] mod tests { use super::*; #[test] fn valid_pack_name_check() { assert!(is_valid_pack_name("my_pack")); assert!(is_valid_pack_name("test-1.0")); assert!(is_valid_pack_name("pack123")); assert!(!is_valid_pack_name("")); assert!(!is_valid_pack_name("../hack")); assert!(!is_valid_pack_name("folder/pack")); assert!(!is_valid_pack_name("pack\\test")); assert!(!is_valid_pack_name("invalid char")); } #[test] fn format_pack_brackets() { let formatted = format_pack("vanilla"); assert!(formatted.to_pretty_console().contains("[vanilla]")); } #[test] fn pack_mcmeta_json_structure() { let mcmeta_content = serde_json::json!({ "pack": { "pack_format": 61, "description": "Test description" } }); let mcmeta_str = serde_json::to_string(&mcmeta_content).unwrap(); assert!(mcmeta_str.contains("\"pack_format\":61")); assert!(mcmeta_str.contains("\"description\":\"Test description\"")); } }