diff --git a/SongTidy.Tests/RenameValidatorTests.cs b/SongTidy.Tests/RenameValidatorTests.cs
new file mode 100644
index 0000000..a94defa
--- /dev/null
+++ b/SongTidy.Tests/RenameValidatorTests.cs
@@ -0,0 +1,370 @@
+using System.IO;
+using System.Linq;
+
+using SongTidy.Models;
+using SongTidy.Services;
+
+namespace SongTidy.Tests;
+
+///
+/// Contains tests for .
+///
+[TestClass]
+public sealed class RenameValidatorTests {
+ private string _tempDirectory = string.Empty;
+
+ ///
+ /// Creates a unique temporary directory for the current test.
+ ///
+ [TestInitialize]
+ public void Initialize() {
+ _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Validation {Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDirectory);
+ }
+
+ ///
+ /// Removes the temporary directory created for the current test.
+ ///
+ [TestCleanup]
+ public void Cleanup() {
+ Directory.Delete(_tempDirectory, recursive: true);
+ }
+
+ #region Per-entry validation
+
+ ///
+ /// Verifies that empty and whitespace-only titles are rejected.
+ ///
+ /// The title to validate.
+ [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));
+ }
+
+ ///
+ /// Verifies that every forbidden file name character is detected in the title.
+ ///
+ /// The character to insert into the title.
+ [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));
+ }
+
+ ///
+ /// Verifies that invalid characters are detected in the artist and comment fields.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that target names ending with a dot or a space are rejected.
+ ///
+ /// The title that produces the invalid target ending.
+ [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));
+ }
+
+ ///
+ /// Verifies that missing source files are detected.
+ ///
+ [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
+
+ ///
+ /// Verifies that duplicate source paths are reported for every affected row.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that source path comparison is case-sensitive.
+ ///
+ [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
+
+ ///
+ /// Verifies that duplicate target paths are detected case-insensitively.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that equal target names in different directories do not conflict.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that an existing target file is reported as a conflict.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that a target equal to the entry's own source is not a conflict.
+ ///
+ [TestMethod]
+ public void AllowsTargetEqualToOwnSource() {
+ string sourcePath = CreateFile("A.mp3");
+
+ RenameValidationResult result = Validate(CreateItem(sourcePath, "A"));
+
+ Assert.IsTrue(result.IsValid);
+ }
+
+ ///
+ /// Verifies that casing-only changes to the entry's own source are not conflicts.
+ ///
+ [TestMethod]
+ public void AllowsTargetEqualToOwnSourceIgnoringCase() {
+ string sourcePath = CreateFile("A.mp3");
+
+ RenameValidationResult result = Validate(CreateItem(sourcePath, "a"));
+
+ Assert.IsTrue(result.IsValid);
+ }
+
+ ///
+ /// Verifies that a renaming chain such as A to B and B to A is blocked.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that the original file extension is preserved when checking conflicts.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that the selected naming format controls the generated target name.
+ ///
+ [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
+
+ ///
+ /// Verifies that validation collects problems from multiple rules in one pass.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that a clean set of entries produces no issues.
+ ///
+ [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
+
+ ///
+ /// Creates an empty file inside the test directory.
+ ///
+ /// The relative file name, which may include a subdirectory.
+ /// The full path of the created file.
+ 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;
+ }
+
+ ///
+ /// Creates a plan item with the supplied field values.
+ ///
+ /// The source file path.
+ /// The title value.
+ /// The artist value.
+ /// The comment value.
+ /// The created plan item.
+ private static PlanItem CreateItem(string path, string title, string artist = "", string comment = "") {
+ return new PlanItem {
+ Path = path,
+ Artist = artist,
+ Title = title,
+ Comment = comment,
+ };
+ }
+
+ ///
+ /// Validates entries with the artist-first format.
+ ///
+ /// The entries to validate.
+ /// The validation result.
+ private static RenameValidationResult Validate(params PlanItem[] items) {
+ return RenameValidator.Validate(items, RenameFormat.ArtistFirst);
+ }
+
+ ///
+ /// Determines whether the result contains a specific problem for a row.
+ ///
+ /// The validation result to inspect.
+ /// The problem category to find.
+ /// The one-based row number to match.
+ /// when the problem is present.
+ private static bool HasProblem(
+ RenameValidationResult result,
+ RenameValidationProblem problem,
+ int rowNumber) {
+ return result.Issues.Any(issue => issue.Problem == problem && issue.RowNumber == rowNumber);
+ }
+}
diff --git a/SongTidy/Services/RenameValidator.cs b/SongTidy/Services/RenameValidator.cs
new file mode 100644
index 0000000..6050592
--- /dev/null
+++ b/SongTidy/Services/RenameValidator.cs
@@ -0,0 +1,337 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+using SongTidy.Models;
+
+namespace SongTidy.Services;
+
+///
+/// Identifies the category of a rename validation problem.
+///
+public enum RenameValidationProblem {
+ ///
+ /// The title is empty or contains only whitespace.
+ ///
+ EmptyTitle,
+
+ ///
+ /// An editable field contains a character that cannot appear in a file name.
+ ///
+ InvalidFieldCharacter,
+
+ ///
+ /// The generated target name ends with a dot or a space.
+ ///
+ InvalidTargetEnding,
+
+ ///
+ /// The source file no longer exists.
+ ///
+ MissingSourceFile,
+
+ ///
+ /// The same source path appears more than once.
+ ///
+ DuplicateSourcePath,
+
+ ///
+ /// Multiple entries generate the same target path.
+ ///
+ DuplicateTargetName,
+
+ ///
+ /// The target path already exists on disk under a different source path.
+ ///
+ TargetFileConflict,
+}
+
+///
+/// Represents one validation problem associated with a plan row.
+///
+/// The one-based row number of the affected entry.
+/// The problem category.
+/// The user-facing reason for the problem.
+public sealed record RenameValidationIssue(int RowNumber, RenameValidationProblem Problem, string Message);
+
+///
+/// Contains the complete result of a rename validation pass.
+///
+/// All problems found during validation.
+public sealed record RenameValidationResult(IReadOnlyList Issues) {
+ ///
+ /// Gets a value indicating whether validation found no problems.
+ ///
+ public bool IsValid => Issues.Count == 0;
+}
+
+///
+/// Validates that a set of plan entries can be renamed without conflicts.
+///
+public static class RenameValidator {
+ #region Public API
+
+ ///
+ /// Validates all entries and returns every problem found.
+ ///
+ /// The entries to validate.
+ /// The naming format used to generate target names.
+ /// All validation problems, or an empty result when renaming can proceed.
+ public static RenameValidationResult Validate(IReadOnlyList items, RenameFormat format) {
+ List issues = new();
+ List 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
+
+ ///
+ /// Generates target names and full target paths for every entry.
+ ///
+ /// The entries to process.
+ /// The naming format to apply.
+ /// The generated targets in entry order.
+ private static List CreateTargets(IReadOnlyList items, RenameFormat format) {
+ List 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
+
+ ///
+ /// Checks the rules that can be evaluated for one entry at a time.
+ ///
+ /// The entries to validate.
+ /// The generated targets in entry order.
+ /// The issue collection to append to.
+ private static void AddEntryIssues(
+ IReadOnlyList items,
+ List targets,
+ List 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."));
+ }
+ }
+ }
+
+ ///
+ /// Checks all editable fields for characters that cannot appear in a file name.
+ ///
+ /// The entry to inspect.
+ /// The one-based row number.
+ /// The issue collection to append to.
+ private static void AddInvalidCharacterIssues(
+ PlanItem item,
+ int rowNumber,
+ List issues) {
+ AddFieldCharacterIssue(item.Artist, "Artist", rowNumber, issues);
+ AddFieldCharacterIssue(item.Title, "Title", rowNumber, issues);
+ AddFieldCharacterIssue(item.Comment, "Comment", rowNumber, issues);
+ }
+
+ ///
+ /// Adds one issue when a field contains an invalid file name character.
+ ///
+ /// The field value to inspect.
+ /// The localized field name used in the message.
+ /// The one-based row number.
+ /// The issue collection to append to.
+ private static void AddFieldCharacterIssue(
+ string value,
+ string fieldName,
+ int rowNumber,
+ List 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;
+ }
+ }
+ }
+
+ ///
+ /// Determines whether a character is forbidden in a Windows file name.
+ ///
+ /// The character to inspect.
+ /// when the character is invalid.
+ private static bool IsInvalidFileNameCharacter(char character) {
+ return char.IsControl(character)
+ || character is '\\' or '/' or ':' or '*' or '?' or '"' or '<' or '>' or '|';
+ }
+
+ #endregion
+
+ #region Conflict validation
+
+ ///
+ /// Adds issues for source paths that appear more than once.
+ ///
+ /// The entries to inspect.
+ /// The issue collection to append to.
+ private static void AddDuplicateSourceIssues(
+ IReadOnlyList items,
+ List issues) {
+ Dictionary> 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? rows)) {
+ rows = new List();
+ rowsByPath.Add(path, rows);
+ }
+
+ rows.Add(index + 1);
+ }
+
+ // Report every row that participates in a duplicate group.
+ foreach (List 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."));
+ }
+ }
+ }
+
+ ///
+ /// Adds issues for entries that generate the same target path.
+ ///
+ /// The generated targets to inspect.
+ /// The issue collection to append to.
+ private static void AddDuplicateTargetIssues(
+ List targets,
+ List issues) {
+ Dictionary> 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? group)) {
+ group = new List();
+ groups.Add(target.Path, group);
+ }
+
+ group.Add(target);
+ }
+
+ // Report every entry that participates in a duplicate target group.
+ foreach (List 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}"));
+ }
+ }
+ }
+
+ ///
+ /// Adds issues for targets that already exist under a different source path.
+ ///
+ /// The entries to inspect.
+ /// The generated targets in entry order.
+ /// The issue collection to append to.
+ private static void AddDiskConflictIssues(
+ IReadOnlyList items,
+ List targets,
+ List 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
+
+ ///
+ /// Represents a generated target name for one plan entry.
+ ///
+ /// The one-based row number of the entry.
+ /// The target file name including its original extension.
+ /// The full target path in the source directory.
+ private sealed record EntryTarget(int RowNumber, string FileName, string Path);
+
+ #endregion
+}