diff --git a/SongTidy.Tests/RenameFormatsTests.cs b/SongTidy.Tests/RenameFormatsTests.cs
new file mode 100644
index 0000000..929d496
--- /dev/null
+++ b/SongTidy.Tests/RenameFormatsTests.cs
@@ -0,0 +1,146 @@
+using SongTidy.Models;
+
+namespace SongTidy.Tests;
+
+///
+/// Contains tests for .
+///
+[TestClass]
+public sealed class RenameFormatsTests {
+ #region Artist-first format
+
+ ///
+ /// Verifies the artist-first layout when both optional fields are present.
+ ///
+ [TestMethod]
+ public void GeneratesArtistFirstWithArtistAndComment() {
+ AssertStem(RenameFormat.ArtistFirst, "Artist", "Title", "Live", "Artist - Title (Live)");
+ }
+
+ ///
+ /// Verifies the artist-first layout when only the artist is present.
+ ///
+ [TestMethod]
+ public void GeneratesArtistFirstWithArtistOnly() {
+ AssertStem(RenameFormat.ArtistFirst, "Artist", "Title", string.Empty, "Artist - Title");
+ }
+
+ ///
+ /// Verifies the artist-first layout when only the comment is present.
+ ///
+ [TestMethod]
+ public void GeneratesArtistFirstWithCommentOnly() {
+ AssertStem(RenameFormat.ArtistFirst, string.Empty, "Title", "Live", "Title (Live)");
+ }
+
+ ///
+ /// Verifies the artist-first layout when neither optional field is present.
+ ///
+ [TestMethod]
+ public void GeneratesArtistFirstWithTitleOnly() {
+ AssertStem(RenameFormat.ArtistFirst, string.Empty, "Title", string.Empty, "Title");
+ }
+
+ #endregion
+
+ #region Title-first format
+
+ ///
+ /// Verifies the title-first layout when both optional fields are present.
+ ///
+ [TestMethod]
+ public void GeneratesTitleFirstWithArtistAndComment() {
+ AssertStem(RenameFormat.TitleFirst, "Artist", "Title", "Live", "Title (Live) - Artist");
+ }
+
+ ///
+ /// Verifies the title-first layout when only the artist is present.
+ ///
+ [TestMethod]
+ public void GeneratesTitleFirstWithArtistOnly() {
+ AssertStem(RenameFormat.TitleFirst, "Artist", "Title", string.Empty, "Title - Artist");
+ }
+
+ ///
+ /// Verifies the title-first layout when only the comment is present.
+ ///
+ [TestMethod]
+ public void GeneratesTitleFirstWithCommentOnly() {
+ AssertStem(RenameFormat.TitleFirst, string.Empty, "Title", "Live", "Title (Live)");
+ }
+
+ ///
+ /// Verifies the title-first layout when neither optional field is present.
+ ///
+ [TestMethod]
+ public void GeneratesTitleFirstWithTitleOnly() {
+ AssertStem(RenameFormat.TitleFirst, string.Empty, "Title", string.Empty, "Title");
+ }
+
+ #endregion
+
+ #region Registry
+
+ ///
+ /// Verifies that every enum member has a registered format definition.
+ ///
+ [TestMethod]
+ public void RegistersEveryFormat() {
+ foreach (RenameFormat format in Enum.GetValues()) {
+ Assert.IsTrue(
+ RenameFormats.Definitions.ContainsKey(format),
+ $"Missing definition for {format}.");
+ }
+ }
+
+ ///
+ /// Verifies that an unregistered enum value is rejected.
+ ///
+ [TestMethod]
+ public void ThrowsForUnregisteredFormat() {
+ Assert.Throws(
+ () => RenameFormats.GenerateStem((RenameFormat)999, "Artist", "Title", "Live"));
+ }
+
+ #endregion
+
+ #region Edge cases
+
+ ///
+ /// Verifies that whitespace-only optional fields are treated as missing.
+ ///
+ [TestMethod]
+ public void TreatsWhitespaceOnlyOptionalFieldsAsMissing() {
+ AssertStem(RenameFormat.ArtistFirst, " ", "Title", "\t", "Title");
+ }
+
+ ///
+ /// Verifies that a null title is rejected.
+ ///
+ [TestMethod]
+ public void ThrowsForNullTitle() {
+ Assert.Throws(
+ () => RenameFormats.GenerateStem(RenameFormat.ArtistFirst, "Artist", null!, "Live"));
+ }
+
+ #endregion
+
+ ///
+ /// Generates a file name stem and asserts the result.
+ ///
+ /// The format to apply.
+ /// The artist value.
+ /// The title value.
+ /// The comment value.
+ /// The expected generated stem.
+ private static void AssertStem(
+ RenameFormat format,
+ string artist,
+ string title,
+ string comment,
+ string expected) {
+ string actual = RenameFormats.GenerateStem(format, artist, title, comment);
+
+ Assert.AreEqual(expected, actual, $"Unexpected stem for {format}.");
+ }
+}
diff --git a/SongTidy/Models/RenameFormats.cs b/SongTidy/Models/RenameFormats.cs
new file mode 100644
index 0000000..a53620a
--- /dev/null
+++ b/SongTidy/Models/RenameFormats.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Xml.Serialization;
+
+namespace SongTidy.Models;
+
+///
+/// Identifies a supported layout for generated target file names.
+///
+public enum RenameFormat {
+ ///
+ /// Places the artist before the title.
+ ///
+ [XmlEnum("ArtistFirst")]
+ ArtistFirst,
+
+ ///
+ /// Places the title before the artist.
+ ///
+ [XmlEnum("TitleFirst")]
+ TitleFirst,
+}
+
+///
+/// Represents the artist, title, and comment values used to build a target file name.
+///
+/// The artist value, which may be empty.
+/// The title value.
+/// The comment value, which may be empty.
+public sealed record RenameFields(string Artist, string Title, string Comment);
+
+///
+/// Formats name fields into a target file name stem.
+///
+/// The fields to format.
+/// The formatted file name stem.
+public delegate string RenameFormatFormatter(RenameFields fields);
+
+///
+/// Defines the formatter functions used to generate a target file name for one format.
+///
+/// The formatter used when both optional fields are present.
+/// The formatter used when only the artist is present.
+/// The formatter used when only the comment is present.
+/// The formatter used when neither optional field is present.
+public sealed record RenameFormatDefinition(
+ RenameFormatFormatter ArtistAndComment,
+ RenameFormatFormatter ArtistOnly,
+ RenameFormatFormatter CommentOnly,
+ RenameFormatFormatter Plain);
+
+///
+/// Provides the registered target file name formats and generates file name stems from them.
+///
+public static class RenameFormats {
+ #region Registry
+
+ private static readonly ReadOnlyDictionary s_definitions =
+ new ReadOnlyDictionary(
+ new Dictionary {
+ [RenameFormat.ArtistFirst] = new RenameFormatDefinition(
+ ArtistAndComment: static fields => $"{fields.Artist} - {fields.Title} ({fields.Comment})",
+ ArtistOnly: static fields => $"{fields.Artist} - {fields.Title}",
+ CommentOnly: static fields => $"{fields.Title} ({fields.Comment})",
+ Plain: static fields => fields.Title),
+ [RenameFormat.TitleFirst] = new RenameFormatDefinition(
+ ArtistAndComment: static fields => $"{fields.Title} ({fields.Comment}) - {fields.Artist}",
+ ArtistOnly: static fields => $"{fields.Title} - {fields.Artist}",
+ CommentOnly: static fields => $"{fields.Title} ({fields.Comment})",
+ Plain: static fields => fields.Title),
+ });
+
+ ///
+ /// Gets the registered format definitions keyed by format.
+ ///
+ public static IReadOnlyDictionary Definitions => s_definitions;
+
+ ///
+ /// Looks up the definition registered for a format.
+ ///
+ /// The format to look up.
+ /// The registered definition.
+ /// The format is not registered.
+ private static RenameFormatDefinition GetDefinition(RenameFormat format) {
+ if (!s_definitions.TryGetValue(format, out RenameFormatDefinition? definition)) {
+ throw new ArgumentOutOfRangeException(nameof(format), format, "The format is not registered.");
+ }
+
+ return definition;
+ }
+
+ #endregion
+
+ #region Generation
+
+ ///
+ /// Generates the target file name stem for the supplied fields and format.
+ ///
+ /// The naming format to apply.
+ /// The artist value, which may be empty.
+ /// The title value, which is always inserted.
+ /// The comment value, which may be empty.
+ ///
+ /// The generated file name stem. The caller appends the original extension because
+ /// extensions are not part of the format definition.
+ ///
+ ///
+ /// Whitespace-only optional fields are treated as missing.
+ ///
+ public static string GenerateStem(RenameFormat format, string artist, string title, string comment) {
+ ArgumentNullException.ThrowIfNull(artist);
+ ArgumentNullException.ThrowIfNull(title);
+ ArgumentNullException.ThrowIfNull(comment);
+
+ RenameFormatDefinition definition = GetDefinition(format);
+ bool hasArtist = !string.IsNullOrWhiteSpace(artist);
+ bool hasComment = !string.IsNullOrWhiteSpace(comment);
+ RenameFormatFormatter formatter;
+
+ // Select the formatter that matches which optional fields are present.
+ if (hasArtist && hasComment) {
+ formatter = definition.ArtistAndComment;
+ } else if (hasArtist) {
+ formatter = definition.ArtistOnly;
+ } else if (hasComment) {
+ formatter = definition.CommentOnly;
+ } else {
+ formatter = definition.Plain;
+ }
+
+ return formatter(new RenameFields(artist, title, comment));
+ }
+
+ #endregion
+}