Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/knowledgebase/cli-selection-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
39 changes: 31 additions & 8 deletions src/ModSync.Core/CLI/ModBuildConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Select { get; set; }

[Option("source-path", Required = false, HelpText = "Path to source directory containing downloaded mod files")]
Expand Down Expand Up @@ -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<string> Select { get; set; }

[Option("source-path", Required = false, HelpText = "Path to source directory containing downloaded mod files")]
Expand Down Expand Up @@ -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<string> Select { get; set; }

[Option("full", Required = false, Default = false, HelpText = "Perform full validation including environment checks (requires --game-dir and --source-dir)")]
Expand Down Expand Up @@ -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<string> 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).")]
Expand Down Expand Up @@ -1269,7 +1269,7 @@ private static async Task<DownloadCacheService> DownloadAllModFilesAsync(List<Mo
return downloadCache;
}

private static void ApplySelectionFilters(
internal static void ApplySelectionFilters(
List<ModComponent> components,
IEnumerable<string> selections)
{
Expand All @@ -1289,6 +1289,7 @@ private static void ApplySelectionFilters(

var selectedCategories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var selectedTiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var selectedModNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

foreach (string selection in selections)
{
Expand All @@ -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;
}

Expand All @@ -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'");
}
}

Expand All @@ -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)
{
Expand Down Expand Up @@ -1384,7 +1403,7 @@ private static void ApplySelectionFilters(
includeByTier = true;
}

if (includeByCategory && includeByTier)
if (includeByModName && includeByCategory && includeByTier)
{
component.IsSelected = true;
selectedCount++;
Expand All @@ -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)}");
Expand Down
76 changes: 76 additions & 0 deletions src/ModSync.Tests/CliSelectionFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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<ModComponent>
{
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<ModComponent>
{
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<ModComponent>
{
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<string> { category },
};
}
}
}