Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions PlexCleaner/CommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class CommandLineOptions
public string ResultsFile { get; set; } = string.Empty;
public string SchemaFile { get; set; } = string.Empty;
public string SettingsFile { get; set; } = string.Empty;
public string PluginAssembly { get; set; } = string.Empty;
public FileInfo? PluginAssembly { get; set; }
}

public class CommandLineParser
Expand Down Expand Up @@ -131,15 +131,25 @@ private class CommandHandler(Func<ParseResult, int> action) : SynchronousCommand
HelpName = "boolean",
};

private readonly Option<string> _pluginAssemblyOption = new("--pluginassembly")
// FileInfo with AcceptExistingOnly validates the path exists at parse time, so the plugin loader
// does not need to re-check it. Both Required and the existence check are gated to non-AOT builds so
// that under AOT the custom command still reaches CustomCommand and emits the non-AOT error.
#if PLUGINS
private readonly Option<FileInfo> _pluginAssemblyOption = new Option<FileInfo>(
Comment thread
ptr727 marked this conversation as resolved.
"--pluginassembly"
)
{
Description = "Path to a plugin assembly implementing IProcessPlugin",
HelpName = "filepath",
// Not required in AOT builds so custom can reach CustomCommand and emit the non-AOT error
#if PLUGINS
Required = true,
#endif
}.AcceptExistingOnly();
#else
private readonly Option<FileInfo> _pluginAssemblyOption = new("--pluginassembly")
{
Description = "Path to a plugin assembly implementing IProcessPlugin",
HelpName = "filepath",
};
#endif

Comment thread
ptr727 marked this conversation as resolved.
private RootCommand CreateRootCommand()
{
Expand Down Expand Up @@ -440,7 +450,7 @@ public CommandLineOptions Bind()
ResultsFile = Result.GetValue(_resultsFileOption) ?? string.Empty,
SchemaFile = Result.GetValue(_schemaFileOption) ?? string.Empty,
SettingsFile = Result.GetValue(_settingsFileOption) ?? string.Empty,
PluginAssembly = Result.GetValue(_pluginAssemblyOption) ?? string.Empty,
PluginAssembly = Result.GetValue(_pluginAssemblyOption),
};

return options;
Expand Down
35 changes: 13 additions & 22 deletions PlexCleaner/PluginLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,28 +59,18 @@ public static class PluginLoader
"Loads a plugin assembly and discovers IProcessPlugin via reflection"
)]
[RequiresDynamicCode("Loads a plugin assembly at runtime")]
public static IProcessPlugin? Load(string assemblyPath)
public static IProcessPlugin? Load(FileInfo assemblyFile)
{
// An empty path would resolve to the current directory and report a misleading error
if (string.IsNullOrWhiteSpace(assemblyPath))
{
Log.Error("Plugin assembly path is empty");
return null;
}
ArgumentNullException.ThrowIfNull(assemblyFile);

// Resolve inside the try so a malformed path (GetFullPath throws) fails cleanly with a log
string fullPath = assemblyPath;
// Start with the safe file name and upgrade to the resolved full path inside the try, so the
// catch can log the most specific location available without FullName throwing before the try
string assemblyPath = assemblyFile.Name;
try
Comment thread
ptr727 marked this conversation as resolved.
Comment thread
ptr727 marked this conversation as resolved.
{
fullPath = Path.GetFullPath(assemblyPath);
if (!File.Exists(fullPath))
{
Log.Error("Plugin assembly not found : {AssemblyPath}", fullPath);
return null;
}

PluginLoadContext context = new(fullPath);
Assembly assembly = context.LoadFromAssemblyPath(fullPath);
assemblyPath = assemblyFile.FullName;
PluginLoadContext context = new(assemblyPath);
Assembly assembly = context.LoadFromAssemblyPath(assemblyPath);

// Require exactly one concrete IProcessPlugin implementation
List<Type> pluginTypes =
Expand All @@ -97,7 +87,7 @@ .. assembly
Log.Error(
"Plugin assembly must contain exactly one IProcessPlugin implementation, found {Count} : {AssemblyPath}",
pluginTypes.Count,
fullPath
assemblyPath
);
return null;
}
Expand All @@ -115,13 +105,14 @@ .. assembly
return null;
}

Log.Information("Loaded plugin : {Name} : {AssemblyPath}", plugin.Name, fullPath);
Log.Information("Loaded plugin : {Name} : {AssemblyPath}", plugin.Name, assemblyPath);
return plugin;
}
catch (Exception e) when (Log.Logger.LogAndHandle(e))
{
// Include the assembly path since LogAndHandle only reports the caller member
Log.Error("Failed to load plugin : {AssemblyPath}", fullPath);
// Log the resolved path (falls back to the file name if FullName threw) since LogAndHandle
// only reports the caller member
Log.Error("Failed to load plugin : {AssemblyPath}", assemblyPath);
return null;
}
}
Expand Down
8 changes: 7 additions & 1 deletion PlexCleaner/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,13 @@ public static int CustomCommand()
return MakeExitCode(ExitCode.Error);
}

// Load the plugin after config and tools are initialized
// Load the plugin after config and tools are initialized (the option is required, so the
// path is always present here)
if (Options.PluginAssembly == null)
{
Log.Error("Plugin assembly path is required");
return MakeExitCode(ExitCode.Error);
}
IProcessPlugin? plugin = PluginLoader.Load(Options.PluginAssembly);
if (plugin == null)
{
Expand Down
39 changes: 39 additions & 0 deletions PlexCleanerTests/CommandLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,45 @@ public void Parse_Commandline_TestMediaInfo(params string[] args)
_ = options.ThreadCount.Should().Be(2);
}

[Fact]
public void Parse_Commandline_Custom_ExistingPluginAssembly_Binds()
{
// AcceptExistingOnly validates the path at parse time, so use a file that exists
string existing = typeof(CommandLineTests).Assembly.Location;
string[] args =
[
"custom",
"--settingsfile=settings.json",
$"--pluginassembly={existing}",
"--mediafiles=/data/foo",
];

CommandLineParser parser = new(args);
_ = parser.Result.Errors.Should().BeEmpty();
_ = parser.Result.CommandResult.Command.Name.Should().Be("custom");

CommandLineOptions options = parser.Bind();
FileInfo? pluginAssembly = options.PluginAssembly;
_ = pluginAssembly.Should().NotBeNull();
_ = pluginAssembly.FullName.Should().Be(new FileInfo(existing).FullName);
}

[Fact]
public void Parse_Commandline_Custom_MissingPluginAssembly_Fails()
{
// A non-existent plugin path fails at parse time via AcceptExistingOnly
string[] args =
[
"custom",
"--settingsfile=settings.json",
"--pluginassembly=/does/not/exist.dll",
"--mediafiles=/data/foo",
];

CommandLineParser parser = new(args);
_ = parser.Result.Errors.Should().NotBeEmpty();
}

[Theory]
[InlineData("--help")]
[InlineData("--version")]
Expand Down
4 changes: 2 additions & 2 deletions PlexCleanerTests/PluginLoaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class PluginLoaderTests
[Fact]
public void Load_ExamplePlugin_ReturnsInitializedPlugin()
{
IProcessPlugin? plugin = PluginLoader.Load(ExamplePluginPath);
IProcessPlugin? plugin = PluginLoader.Load(new FileInfo(ExamplePluginPath));

// A non-null typed return proves the loaded type satisfies the host's IProcessPlugin
// (single shared type identity) and that Initialize accepted the host
Expand All @@ -26,7 +26,7 @@ public void Load_ExamplePlugin_ReturnsInitializedPlugin()
public void Load_MissingAssembly_ReturnsNull()
{
IProcessPlugin? plugin = PluginLoader.Load(
Path.Combine(AppContext.BaseDirectory, "DoesNotExist.dll")
new FileInfo(Path.Combine(AppContext.BaseDirectory, "DoesNotExist.dll"))
);

_ = plugin.Should().BeNull();
Expand Down