diff --git a/BannerlordModEditor.Common.Tests/Services/ModProjectServiceTests.cs b/BannerlordModEditor.Common.Tests/Services/ModProjectServiceTests.cs new file mode 100644 index 00000000..c351cc2b --- /dev/null +++ b/BannerlordModEditor.Common.Tests/Services/ModProjectServiceTests.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; +using BannerlordModEditor.Common.Services; +using BannerlordModEditor.Common.Models.SubModule; + +namespace BannerlordModEditor.Common.Tests.Services +{ + public class ModProjectServiceTests + { + private readonly ModProjectService _service; + + public ModProjectServiceTests() + { + _service = new ModProjectService(new SubModuleLoader()); + } + + [Fact] + public void ValidateModName_ReturnsTrueForValidName() + { + Assert.True(_service.ValidateModName("MyMod")); + Assert.True(_service.ValidateModName("My_Cool_Mod")); + Assert.True(_service.ValidateModName("Mod123")); + } + + [Fact] + public void ValidateModName_ReturnsFalseForInvalidName() + { + Assert.False(_service.ValidateModName("")); + Assert.False(_service.ValidateModName("A")); + Assert.False(_service.ValidateModName("Mod with spaces")); + Assert.False(_service.ValidateModName("Mod@Special")); + } + + [Fact] + public void GenerateModId_ReturnsCorrectId() + { + Assert.Equal("MyMod", _service.GenerateModId("My Mod")); + Assert.Equal("MyCoolMod", _service.GenerateModId("My-Cool-Mod")); + Assert.Equal("TestMod123", _service.GenerateModId("Test Mod 123")); + } + + [Fact] + public async Task CreateModProjectAsync_CreatesProjectStructure() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var options = new ModProjectOptions + { + ModName = "TestMod", + ModId = "TestMod", + OutputDirectory = tempDir, + UseButrTemplate = true + }; + + var result = await _service.CreateModProjectAsync(options); + + Assert.True(result.Success); + Assert.True(Directory.Exists(result.ProjectPath)); + Assert.True(File.Exists(Path.Combine(result.ProjectPath, "SubModule.xml"))); + Assert.True(File.Exists(Path.Combine(result.ProjectPath, "TestMod.csproj"))); + Assert.True(Directory.Exists(Path.Combine(result.ProjectPath, "ModuleData"))); + Assert.True(File.Exists(Path.Combine(result.ProjectPath, ".gitignore"))); + Assert.True(File.Exists(Path.Combine(result.ProjectPath, "README.md"))); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void CreateModProject_ReturnsErrorForInvalidName() + { + var options = new ModProjectOptions + { + ModName = "A", + OutputDirectory = "/tmp" + }; + + var result = _service.CreateModProject(options); + + Assert.False(result.Success); + Assert.Contains("Invalid mod name", result.ErrorMessage); + } + + [Fact] + public void CreateModProject_ReturnsErrorForExistingDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var modDir = Path.Combine(tempDir, "ExistingMod"); + Directory.CreateDirectory(modDir); + + try + { + var options = new ModProjectOptions + { + ModName = "ExistingMod", + OutputDirectory = tempDir + }; + + var result = _service.CreateModProject(options); + + Assert.False(result.Success); + Assert.Contains("already exists", result.ErrorMessage); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void CreateModProject_CreatesValidSubModuleXml() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var options = new ModProjectOptions + { + ModName = "XmlTestMod", + ModId = "XmlTestMod", + OutputDirectory = tempDir, + DependedModules = new() { "Native", "Sandbox" } + }; + + var result = _service.CreateModProject(options); + + Assert.True(result.Success); + + var loader = new SubModuleLoader(); + var subModule = loader.Load(Path.Combine(result.ProjectPath, "SubModule.xml")); + + Assert.NotNull(subModule); + Assert.Equal("XmlTestMod", subModule.Name); + Assert.Equal("XmlTestMod", subModule.Id); + Assert.Equal(2, subModule.DependedModules.Count); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + } +} diff --git a/BannerlordModEditor.Common/Services/ModProjectService.cs b/BannerlordModEditor.Common/Services/ModProjectService.cs new file mode 100644 index 00000000..42a9ad62 --- /dev/null +++ b/BannerlordModEditor.Common/Services/ModProjectService.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using BannerlordModEditor.Common.Models.SubModule; + +namespace BannerlordModEditor.Common.Services +{ + public class ModProjectOptions + { + public string ModName { get; set; } = string.Empty; + public string ModId { get; set; } = string.Empty; + public string OutputDirectory { get; set; } = string.Empty; + public bool UseButrTemplate { get; set; } = true; + public string GameVersion { get; set; } = "Latest"; + public bool CreateSubModuleXml { get; set; } = true; + public bool CreateCsproj { get; set; } = true; + public bool CreateModuleDataFolder { get; set; } = true; + public List DependedModules { get; set; } = new() { "Native" }; + } + + public class ModProjectResult + { + public bool Success { get; set; } + public string ProjectPath { get; set; } = string.Empty; + public List CreatedFiles { get; set; } = new(); + public List Errors { get; set; } = new(); + public string? ErrorMessage { get; set; } + } + + public interface IModProjectService + { + Task CreateModProjectAsync(ModProjectOptions options); + ModProjectResult CreateModProject(ModProjectOptions options); + bool ValidateModName(string modName); + string GenerateModId(string modName); + } + + public class ModProjectService : IModProjectService + { + private readonly ISubModuleLoader _subModuleLoader; + + public ModProjectService(ISubModuleLoader subModuleLoader) + { + _subModuleLoader = subModuleLoader; + } + + public async Task CreateModProjectAsync(ModProjectOptions options) + { + return await Task.Run(() => CreateModProject(options)); + } + + public ModProjectResult CreateModProject(ModProjectOptions options) + { + var result = new ModProjectResult(); + + try + { + if (!ValidateModName(options.ModName)) + { + result.Success = false; + result.ErrorMessage = "Invalid mod name. Use only letters, numbers, and underscores."; + result.Errors.Add(result.ErrorMessage); + return result; + } + + var modId = string.IsNullOrEmpty(options.ModId) + ? GenerateModId(options.ModName) + : options.ModId; + + var projectPath = Path.Combine(options.OutputDirectory, options.ModName); + if (Directory.Exists(projectPath)) + { + result.Success = false; + result.ErrorMessage = $"Directory already exists: {projectPath}"; + result.Errors.Add(result.ErrorMessage); + return result; + } + + Directory.CreateDirectory(projectPath); + result.ProjectPath = projectPath; + + if (options.CreateSubModuleXml) + { + var subModule = _subModuleLoader.CreateDefault(options.ModName, modId); + subModule.DependedModules = options.DependedModules + .Select(d => new Models.SubModule.DependedModuleDO { Id = d }) + .ToList(); + + var subModulePath = Path.Combine(projectPath, "SubModule.xml"); + _subModuleLoader.Save(subModule, subModulePath); + result.CreatedFiles.Add(subModulePath); + } + + if (options.CreateCsproj) + { + var csprojPath = Path.Combine(projectPath, $"{options.ModName}.csproj"); + var csprojContent = GenerateCsprojContent(options.ModName, modId, options.UseButrTemplate); + File.WriteAllText(csprojPath, csprojContent); + result.CreatedFiles.Add(csprojPath); + } + + if (options.CreateModuleDataFolder) + { + var moduleDataPath = Path.Combine(projectPath, "ModuleData"); + Directory.CreateDirectory(moduleDataPath); + result.CreatedFiles.Add(moduleDataPath); + + var emptyXmlPath = Path.Combine(moduleDataPath, "example.xml"); + File.WriteAllText(emptyXmlPath, "\n\n"); + result.CreatedFiles.Add(emptyXmlPath); + } + + var binPath = Path.Combine(projectPath, "bin"); + Directory.CreateDirectory(binPath); + + var objPath = Path.Combine(projectPath, "obj"); + Directory.CreateDirectory(objPath); + + var gitignorePath = Path.Combine(projectPath, ".gitignore"); + File.WriteAllText(gitignorePath, "bin/\nobj/\n*.user\n.vs/"); + result.CreatedFiles.Add(gitignorePath); + + var readmePath = Path.Combine(projectPath, "README.md"); + File.WriteAllText(readmePath, $"# {options.ModName}\n\nA Bannerlord Mod created with BannerlordModEditor."); + result.CreatedFiles.Add(readmePath); + + result.Success = true; + } + catch (Exception ex) + { + result.Success = false; + result.ErrorMessage = ex.Message; + result.Errors.Add(ex.Message); + } + + return result; + } + + public bool ValidateModName(string modName) + { + if (string.IsNullOrWhiteSpace(modName)) + return false; + + if (modName.Length < 2 || modName.Length > 50) + return false; + + return modName.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-'); + } + + public string GenerateModId(string modName) + { + var id = modName.Replace(" ", "").Replace("-", ""); + return id.Length > 0 ? id : "Mod"; + } + + private string GenerateCsprojContent(string modName, string modId, bool useButrTemplate) + { + var targetFramework = "net9.0"; + var packageReferences = useButrTemplate + ? @" + + " + : ""; + + return $@" + + + {targetFramework} + {modName} + {modName} + enable + enable + + + + {packageReferences} + + +"; + } + } +}