diff --git a/src/AcDream.Core/Plugins/PluginDiscovery.cs b/src/AcDream.Core/Plugins/PluginDiscovery.cs new file mode 100644 index 0000000..616616c --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginDiscovery.cs @@ -0,0 +1,38 @@ +namespace AcDream.Core.Plugins; + +public sealed record PluginDiscoveryResult( + string PluginDirectory, + PluginManifest? Manifest, + string? Error) +{ + public bool Success => Manifest is not null && Error is null; +} + +public static class PluginDiscovery +{ + public static IReadOnlyList Scan(string pluginsRootDirectory) + { + if (!Directory.Exists(pluginsRootDirectory)) + return Array.Empty(); + + var results = new List(); + foreach (var subdir in Directory.EnumerateDirectories(pluginsRootDirectory)) + { + var manifestPath = Path.Combine(subdir, "plugin.json"); + if (!File.Exists(manifestPath)) + continue; + + try + { + var json = File.ReadAllText(manifestPath); + var manifest = PluginManifest.Parse(json); + results.Add(new PluginDiscoveryResult(subdir, manifest, null)); + } + catch (Exception ex) + { + results.Add(new PluginDiscoveryResult(subdir, null, ex.Message)); + } + } + return results; + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs new file mode 100644 index 0000000..03dfec6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs @@ -0,0 +1,91 @@ +using AcDream.Core.Plugins; + +namespace AcDream.Core.Tests.Plugins; + +public class PluginDiscoveryTests : IDisposable +{ + private readonly string _tempDir; + + public PluginDiscoveryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "acdream-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private string WriteManifest(string folder, string json) + { + var dir = Path.Combine(_tempDir, folder); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "plugin.json"); + File.WriteAllText(path, json); + return dir; + } + + private const string ValidManifest = """ + { + "id": "acdream.example", + "displayName": "Example", + "version": "0.1.0", + "entryDll": "example.dll", + "apiVersion": 1 + } + """; + + [Fact] + public void Scan_EmptyDirectory_ReturnsEmpty() + { + var results = PluginDiscovery.Scan(_tempDir); + Assert.Empty(results); + } + + [Fact] + public void Scan_NonexistentDirectory_ReturnsEmpty() + { + var results = PluginDiscovery.Scan(Path.Combine(_tempDir, "does-not-exist")); + Assert.Empty(results); + } + + [Fact] + public void Scan_OneValidPlugin_ReturnsOneSuccess() + { + var pluginDir = WriteManifest("example", ValidManifest); + + var results = PluginDiscovery.Scan(_tempDir); + + var single = Assert.Single(results); + Assert.True(single.Success); + Assert.NotNull(single.Manifest); + Assert.Equal("acdream.example", single.Manifest!.Id); + Assert.Equal(pluginDir, single.PluginDirectory); + } + + [Fact] + public void Scan_SubdirWithoutManifest_IsSkipped() + { + Directory.CreateDirectory(Path.Combine(_tempDir, "noplugin")); + WriteManifest("real", ValidManifest); + + var results = PluginDiscovery.Scan(_tempDir); + + Assert.Single(results); + } + + [Fact] + public void Scan_InvalidManifest_ReturnsFailureButKeepsGoing() + { + WriteManifest("broken", "{ not json"); + WriteManifest("ok", ValidManifest); + + var results = PluginDiscovery.Scan(_tempDir).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, r => !r.Success && r.Error != null); + Assert.Contains(results, r => r.Success); + } +}