-
Notifications
You must be signed in to change notification settings - Fork 1
feat(#11): Auto-detect project type #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,146 @@ | ||||||||||||||||||||||||||||||
| using System; | ||||||||||||||||||||||||||||||
| using System.IO; | ||||||||||||||||||||||||||||||
| using System.Threading.Tasks; | ||||||||||||||||||||||||||||||
| using Xunit; | ||||||||||||||||||||||||||||||
| using BannerlordModEditor.Common.Services; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| namespace BannerlordModEditor.Common.Tests.Services | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| public class ProjectTypeDetectorTests | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| private readonly ProjectTypeDetector _detector; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public ProjectTypeDetectorTests() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| _detector = new ProjectTypeDetector(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void DetectProjectType_ReturnsUnknownForNonExistentPath() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var result = _detector.DetectProjectType("/nonexistent/path"); | ||||||||||||||||||||||||||||||
| Assert.Equal(ProjectType.Unknown, result.Type); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void DetectProjectType_ReturnsNativeModForSubmoduleOnly() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||||||||||||||||||||||||||||||
| Directory.CreateDirectory(tempDir); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(tempDir, "SubModule.xml"), "<Module></Module>"); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var result = _detector.DetectProjectType(tempDir); | ||||||||||||||||||||||||||||||
| Assert.Equal(ProjectType.NativeMod, result.Type); | ||||||||||||||||||||||||||||||
| Assert.True(result.HasSubmoduleXml); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| Directory.Delete(tempDir, true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void DetectProjectType_ReturnsCustomTemplateForSubmoduleAndCsproj() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||||||||||||||||||||||||||||||
| Directory.CreateDirectory(tempDir); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(tempDir, "SubModule.xml"), "<Module></Module>"); | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(tempDir, "TestMod.csproj"), "<Project></Project>"); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var result = _detector.DetectProjectType(tempDir); | ||||||||||||||||||||||||||||||
| Assert.Equal(ProjectType.CustomTemplate, result.Type); | ||||||||||||||||||||||||||||||
| Assert.True(result.HasSubmoduleXml); | ||||||||||||||||||||||||||||||
| Assert.True(result.HasCsprojFile); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| Directory.Delete(tempDir, true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void DetectProjectType_ReturnsButrTemplateForButrMarker() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||||||||||||||||||||||||||||||
| var butrDir = Path.Combine(tempDir, "BUTR"); | ||||||||||||||||||||||||||||||
| Directory.CreateDirectory(butrDir); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(tempDir, "SubModule.xml"), "<Module></Module>"); | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(tempDir, "TestMod.csproj"), "<Project></Project>"); | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(butrDir, "Bannerlord.ButterLib.dll"), ""); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var result = _detector.DetectProjectType(tempDir); | ||||||||||||||||||||||||||||||
| Assert.Equal(ProjectType.ButrTemplate, result.Type); | ||||||||||||||||||||||||||||||
| Assert.True(result.HasButrMarker); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| Directory.Delete(tempDir, true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void IsButrTemplateProject_ReturnsTrueForButrProject() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||||||||||||||||||||||||||||||
| var butrDir = Path.Combine(tempDir, "BUTR"); | ||||||||||||||||||||||||||||||
| Directory.CreateDirectory(butrDir); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(butrDir, "marker.txt"), "BUTR marker"); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var result = _detector.IsButrTemplateProject(tempDir); | ||||||||||||||||||||||||||||||
| Assert.True(result); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| Directory.Delete(tempDir, true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void GetModName_ReturnsCsprojName() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||||||||||||||||||||||||||||||
| Directory.CreateDirectory(tempDir); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| File.WriteAllText(Path.Combine(tempDir, "MyCoolMod.csproj"), "<Project></Project>"); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var result = _detector.GetModName(tempDir); | ||||||||||||||||||||||||||||||
| Assert.Equal("MyCoolMod", result); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| Directory.Delete(tempDir, true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||||||
| public void GetModName_ReturnsDirectoryNameWhenNoCsproj() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), "TestModProject"); | ||||||||||||||||||||||||||||||
| Directory.CreateDirectory(tempDir); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var result = _detector.GetModName(tempDir); | ||||||||||||||||||||||||||||||
| Assert.Equal("TestModProject", result); | ||||||||||||||||||||||||||||||
|
Comment on lines
+132
to
+138
|
||||||||||||||||||||||||||||||
| var tempDir = Path.Combine(Path.GetTempPath(), "TestModProject"); | |
| Directory.CreateDirectory(tempDir); | |
| try | |
| { | |
| var result = _detector.GetModName(tempDir); | |
| Assert.Equal("TestModProject", result); | |
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | |
| Directory.CreateDirectory(tempDir); | |
| try | |
| { | |
| var result = _detector.GetModName(tempDir); | |
| Assert.Equal(Path.GetFileName(tempDir), result); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,173 @@ | ||||||||||||||||||||||||||||||||
| using System; | ||||||||||||||||||||||||||||||||
| using System.Collections.Generic; | ||||||||||||||||||||||||||||||||
| using System.IO; | ||||||||||||||||||||||||||||||||
| using System.Linq; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| namespace BannerlordModEditor.Common.Services | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| public enum ProjectType | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| Unknown, | ||||||||||||||||||||||||||||||||
| ButrTemplate, | ||||||||||||||||||||||||||||||||
| CustomTemplate, | ||||||||||||||||||||||||||||||||
| NativeMod | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public class ProjectTypeInfo | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| public ProjectType Type { get; set; } | ||||||||||||||||||||||||||||||||
| public string ProjectPath { get; set; } = string.Empty; | ||||||||||||||||||||||||||||||||
| public bool HasSubmoduleXml { get; set; } | ||||||||||||||||||||||||||||||||
| public bool HasCsprojFile { get; set; } | ||||||||||||||||||||||||||||||||
| public bool HasButrMarker { get; set; } | ||||||||||||||||||||||||||||||||
| public string? ModName { get; set; } | ||||||||||||||||||||||||||||||||
| public string? ModuleDataPath { get; set; } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public interface IProjectTypeDetector | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| ProjectTypeInfo DetectProjectType(string projectPath); | ||||||||||||||||||||||||||||||||
| bool IsButrTemplateProject(string projectPath); | ||||||||||||||||||||||||||||||||
| string? GetModName(string projectPath); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public class ProjectTypeDetector : IProjectTypeDetector | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| private static readonly string[] ButrMarkers = new[] | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| "Bannerlord.ButterLib.dll", | ||||||||||||||||||||||||||||||||
| "Bannerlord.ButterLib", | ||||||||||||||||||||||||||||||||
| "BUTR", | ||||||||||||||||||||||||||||||||
| "BUTRLoadingScreen" | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| private static readonly string[] ButrDirectories = new[] | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| "BUTR", | ||||||||||||||||||||||||||||||||
| "ButterLib", | ||||||||||||||||||||||||||||||||
| "LoadingScreen" | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public ProjectTypeInfo DetectProjectType(string projectPath) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| var info = new ProjectTypeInfo | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| ProjectPath = projectPath, | ||||||||||||||||||||||||||||||||
| Type = ProjectType.Unknown | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!Directory.Exists(projectPath)) | ||||||||||||||||||||||||||||||||
| return info; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| info.HasSubmoduleXml = File.Exists(Path.Combine(projectPath, "SubModule.xml")); | ||||||||||||||||||||||||||||||||
| info.HasCsprojFile = Directory.GetFiles(projectPath, "*.csproj").Any(); | ||||||||||||||||||||||||||||||||
| info.HasButrMarker = CheckForButrMarkers(projectPath); | ||||||||||||||||||||||||||||||||
| info.ModName = GetModName(projectPath); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| var moduleDataDir = Path.Combine(projectPath, "ModuleData"); | ||||||||||||||||||||||||||||||||
| if (Directory.Exists(moduleDataDir)) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| info.ModuleDataPath = moduleDataDir; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (info.HasButrMarker) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| info.Type = ProjectType.ButrTemplate; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| else if (info.HasSubmoduleXml && info.HasCsprojFile) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| info.Type = ProjectType.CustomTemplate; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| else if (info.HasSubmoduleXml) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| info.Type = ProjectType.NativeMod; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return info; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public bool IsButrTemplateProject(string projectPath) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| return DetectProjectType(projectPath).Type == ProjectType.ButrTemplate; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| public string? GetModName(string projectPath) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| if (!Directory.Exists(projectPath)) | ||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| var csprojFiles = Directory.GetFiles(projectPath, "*.csproj"); | ||||||||||||||||||||||||||||||||
| if (csprojFiles.Any()) | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| var csprojPath = csprojFiles.First(); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
| var csprojPath = csprojFiles.First(); | |
| var directoryName = new DirectoryInfo(projectPath).Name; | |
| // Prefer a .csproj whose name matches the directory name, otherwise choose deterministically. | |
| var matchingCsproj = csprojFiles.FirstOrDefault(f => | |
| string.Equals( | |
| Path.GetFileNameWithoutExtension(f), | |
| directoryName, | |
| StringComparison.OrdinalIgnoreCase)); | |
| var csprojPath = matchingCsproj ?? | |
| csprojFiles | |
| .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) | |
| .First(); |
Copilot
AI
Mar 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CheckForButrMarkers traverses the directory tree twice (once via GetAllFiles, then again via Directory.GetDirectories(..., AllDirectories)) and attempts to read the full contents of every file. This can become very slow for large repos and can cause significant IO/memory pressure. Consider a single-pass traversal (e.g., using Directory.EnumerateFileSystemEntries/EnumerateFiles + EnumerateDirectories with early-exit) that checks directory names as they’re discovered, and only reads a small, filtered subset of files (by extension and/or max size) when content scanning is needed.
Copilot
AI
Mar 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty catch { } in GetAllFiles will silently hide IO/permission/path errors and may lead to non-obvious mis-detections (e.g., returning Unknown/missing markers without any diagnosability). Prefer catching specific exceptions (e.g., UnauthorizedAccessException, IOException, PathTooLongException) and either (a) surface a meaningful result (like “inconclusive”) or (b) log at debug/trace level so failures can be investigated without crashing.
| catch | |
| { | |
| } | |
| catch (UnauthorizedAccessException ex) | |
| { | |
| System.Diagnostics.Debug.WriteLine($"[ProjectTypeDetector] Access denied while enumerating directory '{directory}': {ex}"); | |
| } | |
| catch (IOException ex) | |
| { | |
| System.Diagnostics.Debug.WriteLine($"[ProjectTypeDetector] I/O error while enumerating directory '{directory}': {ex}"); | |
| } | |
| catch (PathTooLongException ex) | |
| { | |
| System.Diagnostics.Debug.WriteLine($"[ProjectTypeDetector] Path too long while enumerating directory '{directory}': {ex}"); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hard-coded
"/nonexistent/path"is OS/path-format dependent and can make intent less clear on Windows. Prefer generating a guaranteed-nonexistent path underPath.GetTempPath()(e.g., combining a GUID) to keep the test portable and explicit.