From b1587cee5675bb7e25f3600ac43321748551d6da Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 06:08:05 -0500 Subject: [PATCH] feat(cli): add mod:Name selection for install and validate Extend ApplySelectionFilters with mod:/name: selectors so agents can install or validate a single component from full-build TOMLs without exporting a one-mod file. Document AND semantics with category/tier. --- docs/knowledgebase/cli-selection-semantics.md | 2 +- src/ModSync.Core/CLI/ModBuildConverter.cs | 39 ++++++++-- src/ModSync.Tests/CliSelectionFilterTests.cs | 76 +++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 src/ModSync.Tests/CliSelectionFilterTests.cs diff --git a/docs/knowledgebase/cli-selection-semantics.md b/docs/knowledgebase/cli-selection-semantics.md index f6b80152..ba27f4e0 100644 --- a/docs/knowledgebase/cli-selection-semantics.md +++ b/docs/knowledgebase/cli-selection-semantics.md @@ -16,7 +16,7 @@ Only components already marked `IsSelected = true` in the file are installed. Us ## `install` with `--select` -Repeatable filters: `category:Name`, `tier:Name`. Only matching components stay selected. +Repeatable filters: `category:Name`, `tier:Name`, `mod:Name` (case-insensitive exact or substring match on component `Name`). Only matching components stay selected. Filters combine with AND semantics when multiple types are used together. ## `validate` without `--select` (default) diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index d898b25e..f02905ae 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -397,7 +397,7 @@ public class ConvertOptions : BaseOptions [Option('d', "download", Required = false, HelpText = "Download all mod files to source-path before processing (requires --source-path)")] public bool Download { get; set; } - [Option('s', "select", Required = false, HelpText = "Select components by category or tier (format: 'category:Name' or 'tier:Name'). Can be specified multiple times.")] + [Option('s', "select", Required = false, HelpText = "Select components by category, tier, or mod name (format: 'category:Name', 'tier:Name', or 'mod:Name'). Can be specified multiple times.")] public IEnumerable Select { get; set; } [Option("source-path", Required = false, HelpText = "Path to source directory containing downloaded mod files")] @@ -497,7 +497,7 @@ public class MergeOptions : BaseOptions [Option('d', "download", Required = false, HelpText = "Download all mod files to source-path before processing (requires --source-path)")] public bool Download { get; set; } - [Option('s', "select", Required = false, HelpText = "Select components by category or tier (format: 'category:Name' or 'tier:Name'). Can be specified multiple times.")] + [Option('s', "select", Required = false, HelpText = "Select components by category, tier, or mod name (format: 'category:Name', 'tier:Name', or 'mod:Name'). Can be specified multiple times.")] public IEnumerable Select { get; set; } [Option("source-path", Required = false, HelpText = "Path to source directory containing downloaded mod files")] @@ -576,7 +576,7 @@ public class ValidateOptions : BaseOptions [Option('s', "source-dir", Required = false, HelpText = "Source directory containing mod files (for file existence checks)")] public string SourceDirectory { get; set; } - [Option("select", Required = false, HelpText = "Select components to validate (format: 'category:Name' or 'tier:Name'). Can be specified multiple times.")] + [Option("select", Required = false, HelpText = "Select components to validate (format: 'category:Name', 'tier:Name', or 'mod:Name'). Can be specified multiple times.")] public IEnumerable Select { get; set; } [Option("full", Required = false, Default = false, HelpText = "Perform full validation including environment checks (requires --game-dir and --source-dir)")] @@ -610,7 +610,7 @@ public class InstallOptions : BaseOptions [Option('s', "source-dir", Required = false, HelpText = "Source directory containing mod files (defaults to input file directory)")] public string SourceDirectory { get; set; } - [Option("select", Required = false, HelpText = "Select components to install (format: 'category:Name' or 'tier:Name'). Can be specified multiple times.")] + [Option("select", Required = false, HelpText = "Select components to install (format: 'category:Name', 'tier:Name', or 'mod:Name'). Can be specified multiple times.")] public IEnumerable Select { get; set; } [Option("use-file-selection", Required = false, Default = false, HelpText = "Only install components with IsSelected=true in the file. Default (without this flag and without --select): select all components (full-build style).")] @@ -1269,7 +1269,7 @@ private static async Task DownloadAllModFilesAsync(List components, IEnumerable selections) { @@ -1289,6 +1289,7 @@ private static void ApplySelectionFilters( var selectedCategories = new HashSet(StringComparer.OrdinalIgnoreCase); var selectedTiers = new HashSet(StringComparer.OrdinalIgnoreCase); + var selectedModNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string selection in selections) { @@ -1300,7 +1301,7 @@ private static void ApplySelectionFilters( string[] parts = selection.Split(new[] { ':' }, 2); if (parts.Length != 2) { - Logger.LogWarning($"Invalid selection format: '{selection}'. Expected format: 'category:Name' or 'tier:Name'"); + Logger.LogWarning($"Invalid selection format: '{selection}'. Expected format: 'category:Name', 'tier:Name', or 'mod:Name'"); continue; } @@ -1317,9 +1318,14 @@ private static void ApplySelectionFilters( selectedTiers.Add(value); Logger.LogVerbose($"Added tier filter: {value}"); } + else if (string.Equals(type, "mod", StringComparison.Ordinal) || string.Equals(type, "name", StringComparison.Ordinal)) + { + selectedModNames.Add(value); + Logger.LogVerbose($"Added mod name filter: {value}"); + } else { - Logger.LogWarning($"Unknown selection type: '{type}'. Use 'category' or 'tier'"); + Logger.LogWarning($"Unknown selection type: '{type}'. Use 'category', 'tier', or 'mod'"); } } @@ -1338,6 +1344,19 @@ private static void ApplySelectionFilters( { bool includeByCategory = false; bool includeByTier = false; + bool includeByModName = false; + + if (selectedModNames.Count > 0) + { + includeByModName = selectedModNames.Any(filter => + string.Equals(component.Name, filter, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(component.Name) + && component.Name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0)); + } + else + { + includeByModName = true; + } if (selectedCategories.Count > 0) { @@ -1384,7 +1403,7 @@ private static void ApplySelectionFilters( includeByTier = true; } - if (includeByCategory && includeByTier) + if (includeByModName && includeByCategory && includeByTier) { component.IsSelected = true; selectedCount++; @@ -1396,6 +1415,10 @@ private static void ApplySelectionFilters( } Logger.LogVerbose($"Selection filters applied: {selectedCount}/{components.Count} components selected"); + if (selectedModNames.Count > 0) + { + Logger.LogVerbose($"Mod names: {string.Join(", ", selectedModNames)}"); + } if (selectedCategories.Count > 0) { Logger.LogVerbose($"Categories: {string.Join(", ", selectedCategories)}"); diff --git a/src/ModSync.Tests/CliSelectionFilterTests.cs b/src/ModSync.Tests/CliSelectionFilterTests.cs new file mode 100644 index 00000000..e88126fa --- /dev/null +++ b/src/ModSync.Tests/CliSelectionFilterTests.cs @@ -0,0 +1,76 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; + +using ModSync.Core; +using ModSync.Core.CLI; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class CliSelectionFilterTests + { + [Test] + public void ApplySelectionFilters_ModName_SelectsMatchingComponentOnly() + { + var components = new List + { + CreateComponent("Silent Sion Restoration", "Immersion", "2 - Recommended"), + CreateComponent("The Sith Lords Restored Content Mod", "Restored Content", "1 - Essential"), + CreateComponent("K2 Community Patch", "Bugfix", "1 - Essential"), + }; + + ModBuildConverter.ApplySelectionFilters(components, new[] { "mod:Silent Sion Restoration" }); + + Assert.Multiple(() => + { + Assert.That(components.Count(c => c.IsSelected), Is.EqualTo(1)); + Assert.That(components.Single(c => c.IsSelected).Name, Is.EqualTo("Silent Sion Restoration")); + }); + } + + [Test] + public void ApplySelectionFilters_ModNamePartialMatch_SelectsSubstringHit() + { + var components = new List + { + CreateComponent("Silent Sion Restoration", "Immersion", "2 - Recommended"), + CreateComponent("Silent Sion Extra", "Immersion", "4 - Optional"), + }; + + ModBuildConverter.ApplySelectionFilters(components, new[] { "mod:Silent Sion" }); + + Assert.That(components.Count(c => c.IsSelected), Is.EqualTo(2)); + } + + [Test] + public void ApplySelectionFilters_CategoryStillWorksAlongsideModName() + { + var components = new List + { + CreateComponent("Silent Sion Restoration", "Immersion", "2 - Recommended"), + CreateComponent("Other Immersion Mod", "Immersion", "2 - Recommended"), + }; + + ModBuildConverter.ApplySelectionFilters(components, new[] { "mod:Silent Sion Restoration", "category:Immersion" }); + + Assert.That(components.Count(c => c.IsSelected), Is.EqualTo(1)); + Assert.That(components.Single(c => c.IsSelected).Name, Is.EqualTo("Silent Sion Restoration")); + } + + private static ModComponent CreateComponent(string name, string category, string tier) + { + return new ModComponent + { + Name = name, + Tier = tier, + Category = new List { category }, + }; + } + } +}