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 +}