feat: implement rename validation
This commit is contained in:
1 parent
c815a7d49f
commit
34a588864f
2 files changed
+707
No files matched your search
@@ -0,0 +1,370 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using SongTidy.Models;
|
||||
using SongTidy.Services;
|
||||
|
||||
namespace SongTidy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="RenameValidator"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class RenameValidatorTests {
|
||||
private string _tempDirectory = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a unique temporary directory for the current test.
|
||||
/// </summary>
|
||||
[TestInitialize]
|
||||
public void Initialize() {
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Validation {Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the temporary directory created for the current test.
|
||||
/// </summary>
|
||||
[TestCleanup]
|
||||
public void Cleanup() {
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
|
||||
#region Per-entry validation
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty and whitespace-only titles are rejected.
|
||||
/// </summary>
|
||||
/// <param name="title">The title to validate.</param>
|
||||
[TestMethod]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
public void ReportsEmptyTitle(string title) {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, title));
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.EmptyTitle, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that every forbidden file name character is detected in the title.
|
||||
/// </summary>
|
||||
/// <param name="invalidCharacter">The character to insert into the title.</param>
|
||||
[TestMethod]
|
||||
[DataRow("\\")]
|
||||
[DataRow("/")]
|
||||
[DataRow(":")]
|
||||
[DataRow("*")]
|
||||
[DataRow("?")]
|
||||
[DataRow("\"")]
|
||||
[DataRow("<")]
|
||||
[DataRow(">")]
|
||||
[DataRow("|")]
|
||||
[DataRow("\t")]
|
||||
public void ReportsInvalidCharacterInTitle(string invalidCharacter) {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, "A" + invalidCharacter + "B"));
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.InvalidFieldCharacter, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that invalid characters are detected in the artist and comment fields.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReportsInvalidCharactersInArtistAndComment() {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(
|
||||
CreateItem(sourcePath, "Title", artist: "A\\B", comment: "C:D"));
|
||||
|
||||
Assert.HasCount(
|
||||
2,
|
||||
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.InvalidFieldCharacter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that target names ending with a dot or a space are rejected.
|
||||
/// </summary>
|
||||
/// <param name="title">The title that produces the invalid target ending.</param>
|
||||
[TestMethod]
|
||||
[DataRow("Title.")]
|
||||
[DataRow("Title ")]
|
||||
public void ReportsInvalidTargetEnding(string title) {
|
||||
string sourcePath = CreateFile("Source");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, title));
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.InvalidTargetEnding, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that missing source files are detected.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReportsMissingSourceFile() {
|
||||
string sourcePath = Path.Combine(_tempDirectory, "Missing.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, "Title"));
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.MissingSourceFile, 1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source path validation
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that duplicate source paths are reported for every affected row.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReportsDuplicateSourcePaths() {
|
||||
string sourcePath = CreateFile("Dup.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(
|
||||
CreateItem(sourcePath, "One"),
|
||||
CreateItem(sourcePath, "Two"));
|
||||
|
||||
Assert.HasCount(
|
||||
2,
|
||||
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.DuplicateSourcePath));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 1));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that source path comparison is case-sensitive.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void TreatsSourcePathsAsCaseSensitive() {
|
||||
CreateFile("Case.mp3");
|
||||
PlanItem first = CreateItem(Path.Combine(_tempDirectory, "Case.mp3"), "One");
|
||||
PlanItem second = CreateItem(Path.Combine(_tempDirectory, "case.mp3"), "Two");
|
||||
|
||||
RenameValidationResult result = Validate(first, second);
|
||||
|
||||
Assert.IsFalse(
|
||||
result.Issues.Any(issue => issue.Problem == RenameValidationProblem.DuplicateSourcePath));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Target conflict validation
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that duplicate target paths are detected case-insensitively.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReportsDuplicateTargetNamesCaseInsensitively() {
|
||||
string firstPath = CreateFile("A.mp3");
|
||||
string secondPath = CreateFile("B.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(
|
||||
CreateItem(firstPath, "Same"),
|
||||
CreateItem(secondPath, "same"));
|
||||
|
||||
Assert.HasCount(
|
||||
2,
|
||||
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.DuplicateTargetName));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateTargetName, 1));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateTargetName, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that equal target names in different directories do not conflict.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AllowsSameTargetNameInDifferentDirectories() {
|
||||
string firstPath = CreateFile(Path.Combine("dir1", "A.mp3"));
|
||||
string secondPath = CreateFile(Path.Combine("dir2", "B.mp3"));
|
||||
|
||||
RenameValidationResult result = Validate(
|
||||
CreateItem(firstPath, "Same"),
|
||||
CreateItem(secondPath, "Same"));
|
||||
|
||||
Assert.IsTrue(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an existing target file is reported as a conflict.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReportsTargetFileConflict() {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
CreateFile("Existing.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, "Existing"));
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a target equal to the entry's own source is not a conflict.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AllowsTargetEqualToOwnSource() {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, "A"));
|
||||
|
||||
Assert.IsTrue(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that casing-only changes to the entry's own source are not conflicts.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AllowsTargetEqualToOwnSourceIgnoringCase() {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, "a"));
|
||||
|
||||
Assert.IsTrue(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a renaming chain such as A to B and B to A is blocked.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void BlocksConflictChain() {
|
||||
string aPath = CreateFile("A.mp3");
|
||||
string bPath = CreateFile("B.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(
|
||||
CreateItem(aPath, "B"),
|
||||
CreateItem(bPath, "A"));
|
||||
|
||||
Assert.HasCount(
|
||||
2,
|
||||
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.TargetFileConflict));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the original file extension is preserved when checking conflicts.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void UsesOriginalExtensionForTargetNames() {
|
||||
string sourcePath = CreateFile("Source.flac");
|
||||
CreateFile("Title.flac");
|
||||
|
||||
RenameValidationResult result = Validate(CreateItem(sourcePath, "Title"));
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the selected naming format controls the generated target name.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void UsesSelectedFormatForTargetNames() {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
CreateFile("Title - Artist.mp3");
|
||||
PlanItem item = CreateItem(sourcePath, "Title", artist: "Artist");
|
||||
|
||||
RenameValidationResult result = RenameValidator.Validate(
|
||||
new[] { item },
|
||||
RenameFormat.TitleFirst);
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combined validation
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that validation collects problems from multiple rules in one pass.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReportsAllProblemKinds() {
|
||||
string sourcePath = CreateFile("A.mp3");
|
||||
PlanItem emptyTitle = CreateItem(sourcePath, "");
|
||||
PlanItem missingSource = CreateItem(Path.Combine(_tempDirectory, "Missing.mp3"), "Title");
|
||||
PlanItem invalidCharacter = CreateItem(sourcePath, "Bad/Title");
|
||||
|
||||
RenameValidationResult result = Validate(emptyTitle, missingSource, invalidCharacter);
|
||||
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.EmptyTitle, 1));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.MissingSourceFile, 2));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.InvalidFieldCharacter, 3));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 1));
|
||||
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a clean set of entries produces no issues.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReturnsValidResultForRenameableEntries() {
|
||||
string firstPath = CreateFile("A.mp3");
|
||||
string secondPath = CreateFile("B.mp3");
|
||||
|
||||
RenameValidationResult result = Validate(
|
||||
CreateItem(firstPath, "One"),
|
||||
CreateItem(secondPath, "Two"));
|
||||
|
||||
Assert.IsTrue(result.IsValid);
|
||||
Assert.HasCount(0, result.Issues);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty file inside the test directory.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The relative file name, which may include a subdirectory.</param>
|
||||
/// <returns>The full path of the created file.</returns>
|
||||
private string CreateFile(string fileName) {
|
||||
string path = Path.Combine(_tempDirectory, fileName);
|
||||
string directory = Path.GetDirectoryName(path) ?? _tempDirectory;
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, string.Empty);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a plan item with the supplied field values.
|
||||
/// </summary>
|
||||
/// <param name="path">The source file path.</param>
|
||||
/// <param name="title">The title value.</param>
|
||||
/// <param name="artist">The artist value.</param>
|
||||
/// <param name="comment">The comment value.</param>
|
||||
/// <returns>The created plan item.</returns>
|
||||
private static PlanItem CreateItem(string path, string title, string artist = "", string comment = "") {
|
||||
return new PlanItem {
|
||||
Path = path,
|
||||
Artist = artist,
|
||||
Title = title,
|
||||
Comment = comment,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates entries with the artist-first format.
|
||||
/// </summary>
|
||||
/// <param name="items">The entries to validate.</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
private static RenameValidationResult Validate(params PlanItem[] items) {
|
||||
return RenameValidator.Validate(items, RenameFormat.ArtistFirst);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the result contains a specific problem for a row.
|
||||
/// </summary>
|
||||
/// <param name="result">The validation result to inspect.</param>
|
||||
/// <param name="problem">The problem category to find.</param>
|
||||
/// <param name="rowNumber">The one-based row number to match.</param>
|
||||
/// <returns><see langword="true"/> when the problem is present.</returns>
|
||||
private static bool HasProblem(
|
||||
RenameValidationResult result,
|
||||
RenameValidationProblem problem,
|
||||
int rowNumber) {
|
||||
return result.Issues.Any(issue => issue.Problem == problem && issue.RowNumber == rowNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
using SongTidy.Models;
|
||||
|
||||
namespace SongTidy.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the category of a rename validation problem.
|
||||
/// </summary>
|
||||
public enum RenameValidationProblem {
|
||||
/// <summary>
|
||||
/// The title is empty or contains only whitespace.
|
||||
/// </summary>
|
||||
EmptyTitle,
|
||||
|
||||
/// <summary>
|
||||
/// An editable field contains a character that cannot appear in a file name.
|
||||
/// </summary>
|
||||
InvalidFieldCharacter,
|
||||
|
||||
/// <summary>
|
||||
/// The generated target name ends with a dot or a space.
|
||||
/// </summary>
|
||||
InvalidTargetEnding,
|
||||
|
||||
/// <summary>
|
||||
/// The source file no longer exists.
|
||||
/// </summary>
|
||||
MissingSourceFile,
|
||||
|
||||
/// <summary>
|
||||
/// The same source path appears more than once.
|
||||
/// </summary>
|
||||
DuplicateSourcePath,
|
||||
|
||||
/// <summary>
|
||||
/// Multiple entries generate the same target path.
|
||||
/// </summary>
|
||||
DuplicateTargetName,
|
||||
|
||||
/// <summary>
|
||||
/// The target path already exists on disk under a different source path.
|
||||
/// </summary>
|
||||
TargetFileConflict,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents one validation problem associated with a plan row.
|
||||
/// </summary>
|
||||
/// <param name="RowNumber">The one-based row number of the affected entry.</param>
|
||||
/// <param name="Problem">The problem category.</param>
|
||||
/// <param name="Message">The user-facing reason for the problem.</param>
|
||||
public sealed record RenameValidationIssue(int RowNumber, RenameValidationProblem Problem, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Contains the complete result of a rename validation pass.
|
||||
/// </summary>
|
||||
/// <param name="Issues">All problems found during validation.</param>
|
||||
public sealed record RenameValidationResult(IReadOnlyList<RenameValidationIssue> Issues) {
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether validation found no problems.
|
||||
/// </summary>
|
||||
public bool IsValid => Issues.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a set of plan entries can be renamed without conflicts.
|
||||
/// </summary>
|
||||
public static class RenameValidator {
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Validates all entries and returns every problem found.
|
||||
/// </summary>
|
||||
/// <param name="items">The entries to validate.</param>
|
||||
/// <param name="format">The naming format used to generate target names.</param>
|
||||
/// <returns>All validation problems, or an empty result when renaming can proceed.</returns>
|
||||
public static RenameValidationResult Validate(IReadOnlyList<PlanItem> items, RenameFormat format) {
|
||||
List<RenameValidationIssue> issues = new();
|
||||
List<EntryTarget> targets = CreateTargets(items, format);
|
||||
|
||||
// Check rules that apply to each entry independently.
|
||||
AddEntryIssues(items, targets, issues);
|
||||
|
||||
// Check rules that require comparing entries with each other or with the file system.
|
||||
AddDuplicateSourceIssues(items, issues);
|
||||
AddDuplicateTargetIssues(targets, issues);
|
||||
AddDiskConflictIssues(items, targets, issues);
|
||||
|
||||
// Present problems in row order so the report reads naturally.
|
||||
issues.Sort(static (left, right) => left.RowNumber.CompareTo(right.RowNumber));
|
||||
|
||||
return new RenameValidationResult(issues);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Target generation
|
||||
|
||||
/// <summary>
|
||||
/// Generates target names and full target paths for every entry.
|
||||
/// </summary>
|
||||
/// <param name="items">The entries to process.</param>
|
||||
/// <param name="format">The naming format to apply.</param>
|
||||
/// <returns>The generated targets in entry order.</returns>
|
||||
private static List<EntryTarget> CreateTargets(IReadOnlyList<PlanItem> items, RenameFormat format) {
|
||||
List<EntryTarget> targets = new(items.Count);
|
||||
|
||||
for (int index = 0; index < items.Count; index++) {
|
||||
PlanItem item = items[index];
|
||||
string fileName = RenameFormats.GenerateStem(format, item.Artist, item.Title, item.Comment)
|
||||
+ Path.GetExtension(item.Path);
|
||||
string directory = Path.GetDirectoryName(item.Path) ?? string.Empty;
|
||||
string path = Path.Combine(directory, fileName);
|
||||
|
||||
targets.Add(new EntryTarget(index + 1, fileName, path));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-entry validation
|
||||
|
||||
/// <summary>
|
||||
/// Checks the rules that can be evaluated for one entry at a time.
|
||||
/// </summary>
|
||||
/// <param name="items">The entries to validate.</param>
|
||||
/// <param name="targets">The generated targets in entry order.</param>
|
||||
/// <param name="issues">The issue collection to append to.</param>
|
||||
private static void AddEntryIssues(
|
||||
IReadOnlyList<PlanItem> items,
|
||||
List<EntryTarget> targets,
|
||||
List<RenameValidationIssue> issues) {
|
||||
for (int index = 0; index < items.Count; index++) {
|
||||
PlanItem item = items[index];
|
||||
EntryTarget target = targets[index];
|
||||
int rowNumber = target.RowNumber;
|
||||
|
||||
// The title is mandatory.
|
||||
if (string.IsNullOrWhiteSpace(item.Title)) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
rowNumber,
|
||||
RenameValidationProblem.EmptyTitle,
|
||||
"The title is empty."));
|
||||
}
|
||||
|
||||
AddInvalidCharacterIssues(item, rowNumber, issues);
|
||||
|
||||
// Windows cannot create names that end with a dot or a space.
|
||||
if (target.FileName.EndsWith('.') || target.FileName.EndsWith(' ')) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
rowNumber,
|
||||
RenameValidationProblem.InvalidTargetEnding,
|
||||
"The target file name cannot end with a dot or a space."));
|
||||
}
|
||||
|
||||
// The source file must still exist when the user confirms renaming.
|
||||
if (!File.Exists(item.Path)) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
rowNumber,
|
||||
RenameValidationProblem.MissingSourceFile,
|
||||
"The source file does not exist."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks all editable fields for characters that cannot appear in a file name.
|
||||
/// </summary>
|
||||
/// <param name="item">The entry to inspect.</param>
|
||||
/// <param name="rowNumber">The one-based row number.</param>
|
||||
/// <param name="issues">The issue collection to append to.</param>
|
||||
private static void AddInvalidCharacterIssues(
|
||||
PlanItem item,
|
||||
int rowNumber,
|
||||
List<RenameValidationIssue> issues) {
|
||||
AddFieldCharacterIssue(item.Artist, "Artist", rowNumber, issues);
|
||||
AddFieldCharacterIssue(item.Title, "Title", rowNumber, issues);
|
||||
AddFieldCharacterIssue(item.Comment, "Comment", rowNumber, issues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds one issue when a field contains an invalid file name character.
|
||||
/// </summary>
|
||||
/// <param name="value">The field value to inspect.</param>
|
||||
/// <param name="fieldName">The localized field name used in the message.</param>
|
||||
/// <param name="rowNumber">The one-based row number.</param>
|
||||
/// <param name="issues">The issue collection to append to.</param>
|
||||
private static void AddFieldCharacterIssue(
|
||||
string value,
|
||||
string fieldName,
|
||||
int rowNumber,
|
||||
List<RenameValidationIssue> issues) {
|
||||
foreach (char character in value) {
|
||||
if (IsInvalidFileNameCharacter(character)) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
rowNumber,
|
||||
RenameValidationProblem.InvalidFieldCharacter,
|
||||
$"{fieldName} contains a character that is not allowed in a file name."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a character is forbidden in a Windows file name.
|
||||
/// </summary>
|
||||
/// <param name="character">The character to inspect.</param>
|
||||
/// <returns><see langword="true"/> when the character is invalid.</returns>
|
||||
private static bool IsInvalidFileNameCharacter(char character) {
|
||||
return char.IsControl(character)
|
||||
|| character is '\\' or '/' or ':' or '*' or '?' or '"' or '<' or '>' or '|';
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Conflict validation
|
||||
|
||||
/// <summary>
|
||||
/// Adds issues for source paths that appear more than once.
|
||||
/// </summary>
|
||||
/// <param name="items">The entries to inspect.</param>
|
||||
/// <param name="issues">The issue collection to append to.</param>
|
||||
private static void AddDuplicateSourceIssues(
|
||||
IReadOnlyList<PlanItem> items,
|
||||
List<RenameValidationIssue> issues) {
|
||||
Dictionary<string, List<int>> rowsByPath = new(StringComparer.Ordinal);
|
||||
|
||||
// Group rows by exact source path; comparison is intentionally case-sensitive.
|
||||
for (int index = 0; index < items.Count; index++) {
|
||||
string path = items[index].Path;
|
||||
|
||||
if (!rowsByPath.TryGetValue(path, out List<int>? rows)) {
|
||||
rows = new List<int>();
|
||||
rowsByPath.Add(path, rows);
|
||||
}
|
||||
|
||||
rows.Add(index + 1);
|
||||
}
|
||||
|
||||
// Report every row that participates in a duplicate group.
|
||||
foreach (List<int> rows in rowsByPath.Values) {
|
||||
if (rows.Count < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (int rowNumber in rows) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
rowNumber,
|
||||
RenameValidationProblem.DuplicateSourcePath,
|
||||
"The source path duplicates another entry in the list."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds issues for entries that generate the same target path.
|
||||
/// </summary>
|
||||
/// <param name="targets">The generated targets to inspect.</param>
|
||||
/// <param name="issues">The issue collection to append to.</param>
|
||||
private static void AddDuplicateTargetIssues(
|
||||
List<EntryTarget> targets,
|
||||
List<RenameValidationIssue> issues) {
|
||||
Dictionary<string, List<EntryTarget>> groups = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Group entries by full target path; file systems treat casing as equivalent.
|
||||
foreach (EntryTarget target in targets) {
|
||||
if (!groups.TryGetValue(target.Path, out List<EntryTarget>? group)) {
|
||||
group = new List<EntryTarget>();
|
||||
groups.Add(target.Path, group);
|
||||
}
|
||||
|
||||
group.Add(target);
|
||||
}
|
||||
|
||||
// Report every entry that participates in a duplicate target group.
|
||||
foreach (List<EntryTarget> group in groups.Values) {
|
||||
if (group.Count < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (EntryTarget target in group) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
target.RowNumber,
|
||||
RenameValidationProblem.DuplicateTargetName,
|
||||
$"The target file name duplicates another entry: {target.FileName}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds issues for targets that already exist under a different source path.
|
||||
/// </summary>
|
||||
/// <param name="items">The entries to inspect.</param>
|
||||
/// <param name="targets">The generated targets in entry order.</param>
|
||||
/// <param name="issues">The issue collection to append to.</param>
|
||||
private static void AddDiskConflictIssues(
|
||||
IReadOnlyList<PlanItem> items,
|
||||
List<EntryTarget> targets,
|
||||
List<RenameValidationIssue> issues) {
|
||||
for (int index = 0; index < items.Count; index++) {
|
||||
PlanItem item = items[index];
|
||||
EntryTarget target = targets[index];
|
||||
|
||||
// Renaming a file to itself is not a conflict, even when only casing changes.
|
||||
if (string.Equals(target.Path, item.Path, StringComparison.OrdinalIgnoreCase)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any other existing target would be overwritten or would block the rename.
|
||||
if (File.Exists(target.Path)) {
|
||||
issues.Add(new RenameValidationIssue(
|
||||
target.RowNumber,
|
||||
RenameValidationProblem.TargetFileConflict,
|
||||
$"The target file already exists: {target.FileName}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Nested types
|
||||
|
||||
/// <summary>
|
||||
/// Represents a generated target name for one plan entry.
|
||||
/// </summary>
|
||||
/// <param name="RowNumber">The one-based row number of the entry.</param>
|
||||
/// <param name="FileName">The target file name including its original extension.</param>
|
||||
/// <param name="Path">The full target path in the source directory.</param>
|
||||
private sealed record EntryTarget(int RowNumber, string FileName, string Path);
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in new issue
Block a user