diff --git a/SongTidy.Tests/PlanXmlIOTests.cs b/SongTidy.Tests/PlanXmlIOTests.cs
new file mode 100644
index 0000000..68240fb
--- /dev/null
+++ b/SongTidy.Tests/PlanXmlIOTests.cs
@@ -0,0 +1,311 @@
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Xml.Linq;
+
+using SongTidy.Models;
+using SongTidy.Services;
+
+namespace SongTidy.Tests;
+
+///
+/// Contains tests for .
+///
+[TestClass]
+public sealed class PlanXmlIOTests {
+ #region Document structure
+
+ ///
+ /// Verifies the generated XML root, version, format, and item attributes.
+ ///
+ [TestMethod]
+ public void SavesExpectedDocumentStructure() {
+ SongPlan plan = CreateSamplePlan();
+
+ string xml = SaveToString(plan);
+ XDocument document = XDocument.Parse(xml);
+ XElement root = document.Root!;
+
+ Assert.AreEqual("Plan", root.Name.LocalName);
+ Assert.AreEqual(SongPlan.CurrentVersion.ToString(CultureInfo.InvariantCulture), root.Attribute("version")?.Value);
+ Assert.HasCount(1, root.Attributes());
+ Assert.AreEqual("ArtistFirst", root.Element("Format")?.Value);
+
+ XElement items = root.Element("Items")!;
+ Assert.HasCount(2, items.Elements("Item"));
+
+ XElement first = items.Elements("Item").First();
+ Assert.AreEqual(@"D:\Music\A - B (Live).mp3", first.Attribute("Path")?.Value);
+ Assert.AreEqual("A", first.Attribute("Artist")?.Value);
+ Assert.AreEqual("B", first.Attribute("Title")?.Value);
+ Assert.AreEqual("Live", first.Attribute("Comment")?.Value);
+
+ Assert.IsTrue(
+ xml.StartsWith("", StringComparison.Ordinal),
+ "The XML declaration should specify UTF-8.");
+ }
+
+ ///
+ /// Verifies that empty fields are still written as empty attributes.
+ ///
+ [TestMethod]
+ public void WritesEmptyFieldsAsAttributes() {
+ SongPlan plan = new() {
+ Items = new List {
+ new PlanItem {
+ Path = @"C:\Music\C.mp3",
+ Title = "C",
+ },
+ },
+ };
+
+ string xml = SaveToString(plan);
+ XElement item = XDocument.Parse(xml).Root!.Element("Items")!.Element("Item")!;
+
+ Assert.IsNotNull(item.Attribute("Artist"));
+ Assert.AreEqual(string.Empty, item.Attribute("Artist")!.Value);
+ Assert.IsNotNull(item.Attribute("Comment"));
+ Assert.AreEqual(string.Empty, item.Attribute("Comment")!.Value);
+ }
+
+ #endregion
+
+ #region Round trips
+
+ ///
+ /// Verifies that both format enum values survive a round trip.
+ ///
+ /// The format to round-trip.
+ [TestMethod]
+ [DataRow(RenameFormat.ArtistFirst)]
+ [DataRow(RenameFormat.TitleFirst)]
+ public void RoundTripsFormat(RenameFormat format) {
+ SongPlan plan = new() {
+ Format = format,
+ Items = new List {
+ new PlanItem { Path = @"C:\Music\C.mp3", Title = "C" },
+ },
+ };
+
+ SongPlan loaded = RoundTrip(plan);
+
+ Assert.AreEqual(format, loaded.Format);
+ }
+
+ ///
+ /// Verifies that every field and the version survive a round trip.
+ ///
+ [TestMethod]
+ public void RoundTripsAllFields() {
+ SongPlan plan = CreateSamplePlan();
+
+ SongPlan loaded = RoundTrip(plan);
+
+ Assert.AreEqual(plan.Version, loaded.Version);
+ Assert.AreEqual(plan.Format, loaded.Format);
+ Assert.HasCount(2, loaded.Items);
+ AssertPlanItem(plan.Items[0], loaded.Items[0]);
+ AssertPlanItem(plan.Items[1], loaded.Items[1]);
+ }
+
+ ///
+ /// Verifies that a non-default version attribute is preserved.
+ ///
+ [TestMethod]
+ public void PreservesVersion() {
+ SongPlan plan = CreateSamplePlan();
+ int version = SongPlan.CurrentVersion + 1;
+ plan.Version = version;
+
+ SongPlan loaded = RoundTrip(plan);
+
+ Assert.AreEqual(version, loaded.Version);
+ }
+
+ ///
+ /// Verifies that paths and fields containing spaces and XML special characters round-trip.
+ ///
+ [TestMethod]
+ public void RoundTripsPathsWithSpacesAndSpecialCharacters() {
+ const string path = @"D:\Music\A & B ""quoted"" 'single' - (Live).mp3";
+ SongPlan plan = new() {
+ Items = new List {
+ new PlanItem {
+ Path = path,
+ Artist = "A & B",
+ Title = "Title ",
+ Comment = "\"Comment\"",
+ },
+ },
+ };
+
+ string xml = SaveToString(plan);
+
+ Assert.IsTrue(xml.Contains("&", StringComparison.Ordinal));
+ Assert.IsTrue(xml.Contains(""", StringComparison.Ordinal));
+ Assert.IsTrue(xml.Contains("<", StringComparison.Ordinal));
+ Assert.IsTrue(xml.Contains(">", StringComparison.Ordinal));
+
+ SongPlan loaded = RoundTrip(plan);
+
+ Assert.AreEqual(path, loaded.Items[0].Path);
+ Assert.AreEqual("A & B", loaded.Items[0].Artist);
+ Assert.AreEqual("Title ", loaded.Items[0].Title);
+ Assert.AreEqual("\"Comment\"", loaded.Items[0].Comment);
+ }
+
+ #endregion
+
+ #region Loading
+
+ ///
+ /// Verifies that missing optional attributes are loaded as empty strings.
+ ///
+ [TestMethod]
+ public void LoadsMissingOptionalAttributesAsEmpty() {
+ string xml = $"""
+
+
+ TitleFirst
+
+
+
+
+ """;
+
+ SongPlan plan = LoadFromString(xml);
+
+ Assert.AreEqual(RenameFormat.TitleFirst, plan.Format);
+ Assert.HasCount(1, plan.Items);
+ Assert.AreEqual(@"C:\Music\C.mp3", plan.Items[0].Path);
+ Assert.AreEqual(string.Empty, plan.Items[0].Artist);
+ Assert.AreEqual("C", plan.Items[0].Title);
+ Assert.AreEqual(string.Empty, plan.Items[0].Comment);
+ }
+
+ ///
+ /// Verifies that a missing Items element leaves an empty initialized list.
+ ///
+ [TestMethod]
+ public void LoadsMissingItemsAsEmptyList() {
+ string xml = $"""
+
+
+ ArtistFirst
+
+ """;
+
+ SongPlan plan = LoadFromString(xml);
+
+ Assert.HasCount(0, plan.Items);
+ }
+
+ #endregion
+
+ #region File access
+
+ ///
+ /// Verifies file save and load with spaces in the file path and UTF-8 output without a BOM.
+ ///
+ [TestMethod]
+ public void SavesAndLoadsFileWithSpacesInPath() {
+ string directory = Path.Combine(Path.GetTempPath(), $"SongTidy Plan Test {Guid.NewGuid():N}");
+ string filePath = Path.Combine(directory, "plan with spaces.xml");
+ Directory.CreateDirectory(directory);
+
+ try {
+ SongPlan plan = CreateSamplePlan();
+
+ PlanXmlIO.Save(plan, filePath);
+
+ Assert.IsTrue(File.Exists(filePath));
+
+ byte[] bytes = File.ReadAllBytes(filePath);
+ Assert.AreEqual((byte)'<', bytes[0]);
+
+ string xml = Encoding.UTF8.GetString(bytes);
+ Assert.IsTrue(
+ xml.StartsWith("", StringComparison.Ordinal),
+ "The file should be written as UTF-8 without a BOM.");
+
+ SongPlan loaded = PlanXmlIO.Load(filePath);
+ Assert.AreEqual(plan.Format, loaded.Format);
+ Assert.HasCount(plan.Items.Count, loaded.Items);
+ AssertPlanItem(plan.Items[0], loaded.Items[0]);
+ } finally {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ #endregion
+
+ ///
+ /// Creates a sample plan containing two entries.
+ ///
+ /// The sample plan.
+ private static SongPlan CreateSamplePlan() {
+ return new SongPlan {
+ Format = RenameFormat.ArtistFirst,
+ Items = new List {
+ new PlanItem {
+ Path = @"D:\Music\A - B (Live).mp3",
+ Artist = "A",
+ Title = "B",
+ Comment = "Live",
+ },
+ new PlanItem {
+ Path = @"D:\Music\C.mp3",
+ Artist = string.Empty,
+ Title = "C",
+ Comment = string.Empty,
+ },
+ },
+ };
+ }
+
+ ///
+ /// Saves a plan to an in-memory stream and returns the decoded XML.
+ ///
+ /// The plan to save.
+ /// The generated XML text.
+ private static string SaveToString(SongPlan plan) {
+ using MemoryStream stream = new();
+ PlanXmlIO.Save(plan, stream);
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ ///
+ /// Loads a plan from XML text.
+ ///
+ /// The XML text to load.
+ /// The loaded plan.
+ private static SongPlan LoadFromString(string xml) {
+ using MemoryStream stream = new(Encoding.UTF8.GetBytes(xml));
+ return PlanXmlIO.Load(stream);
+ }
+
+ ///
+ /// Saves and reloads a plan through an in-memory stream.
+ ///
+ /// The plan to round-trip.
+ /// The reloaded plan.
+ private static SongPlan RoundTrip(SongPlan plan) {
+ using MemoryStream stream = new();
+ PlanXmlIO.Save(plan, stream);
+ stream.Position = 0;
+ return PlanXmlIO.Load(stream);
+ }
+
+ ///
+ /// Asserts that two plan items contain the same field values.
+ ///
+ /// The expected item.
+ /// The actual item.
+ private static void AssertPlanItem(PlanItem expected, PlanItem actual) {
+ Assert.AreEqual(expected.Path, actual.Path);
+ Assert.AreEqual(expected.Artist, actual.Artist);
+ Assert.AreEqual(expected.Title, actual.Title);
+ Assert.AreEqual(expected.Comment, actual.Comment);
+ }
+}
diff --git a/SongTidy/Models/PlanItem.cs b/SongTidy/Models/PlanItem.cs
new file mode 100644
index 0000000..545a2fc
--- /dev/null
+++ b/SongTidy/Models/PlanItem.cs
@@ -0,0 +1,32 @@
+using System.Xml.Serialization;
+
+namespace SongTidy.Models;
+
+///
+/// Represents one file entry stored in a plan document.
+///
+public sealed class PlanItem {
+ ///
+ /// Gets or sets the absolute source file path.
+ ///
+ [XmlAttribute("Path")]
+ public string Path { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the artist value.
+ ///
+ [XmlAttribute("Artist")]
+ public string Artist { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the title value.
+ ///
+ [XmlAttribute("Title")]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the comment value.
+ ///
+ [XmlAttribute("Comment")]
+ public string Comment { get; set; } = string.Empty;
+}
diff --git a/SongTidy/Models/SongPlan.cs b/SongTidy/Models/SongPlan.cs
new file mode 100644
index 0000000..df4c275
--- /dev/null
+++ b/SongTidy/Models/SongPlan.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Xml.Serialization;
+
+namespace SongTidy.Models;
+
+///
+/// Represents a saved plan document containing the selected format and all entries.
+///
+[XmlRoot("Plan")]
+public sealed class SongPlan {
+ ///
+ /// The plan file version written for new plans.
+ ///
+ public const int CurrentVersion = 1;
+
+ ///
+ /// Gets or sets the plan file version.
+ ///
+ [XmlAttribute("version")]
+ public int Version { get; set; } = CurrentVersion;
+
+ ///
+ /// Gets or sets the naming format used by the plan.
+ ///
+ [XmlElement("Format")]
+ public RenameFormat Format { get; set; } = RenameFormat.ArtistFirst;
+
+ ///
+ /// Gets or sets the entries stored in the plan.
+ ///
+ [XmlArray("Items")]
+ [XmlArrayItem("Item")]
+ public List Items { get; set; } = new();
+}
diff --git a/SongTidy/Services/PlanXmlIO.cs b/SongTidy/Services/PlanXmlIO.cs
new file mode 100644
index 0000000..9be50de
--- /dev/null
+++ b/SongTidy/Services/PlanXmlIO.cs
@@ -0,0 +1,105 @@
+using System.IO;
+using System.Text;
+using System.Xml;
+using System.Xml.Serialization;
+
+using SongTidy.Models;
+
+namespace SongTidy.Services;
+
+///
+/// Reads and writes plan documents in the application's XML format.
+///
+public static class PlanXmlIO {
+ #region Stream access
+
+ ///
+ /// Writes a plan to a stream as UTF-8 XML.
+ ///
+ /// The plan to write.
+ /// The destination stream. The stream is left open.
+ public static void Save(SongPlan plan, Stream stream) {
+ XmlSerializer serializer = new(typeof(SongPlan));
+
+ // Suppress xsi and xsd namespace attributes to keep the plan file compact.
+ XmlSerializerNamespaces namespaces = new();
+ namespaces.Add(string.Empty, string.Empty);
+
+ using XmlWriter writer = XmlWriter.Create(stream, CreateWriterSettings());
+ serializer.Serialize(writer, plan, namespaces);
+ }
+
+ ///
+ /// Reads a plan from a stream.
+ ///
+ /// The source stream. The stream is left open.
+ /// The deserialized plan.
+ /// The document does not contain a plan.
+ public static SongPlan Load(Stream stream) {
+ XmlSerializer serializer = new(typeof(SongPlan));
+
+ using XmlReader reader = XmlReader.Create(stream, CreateReaderSettings());
+ SongPlan? plan = (SongPlan?)serializer.Deserialize(reader);
+
+ if (plan is null) {
+ throw new InvalidDataException("The XML document does not contain a plan.");
+ }
+
+ return plan;
+ }
+
+ #endregion
+
+ #region File access
+
+ ///
+ /// Writes a plan to a UTF-8 XML file, overwriting an existing file.
+ ///
+ /// The plan to write.
+ /// The destination file path.
+ public static void Save(SongPlan plan, string filePath) {
+ using FileStream stream = new(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
+ Save(plan, stream);
+ }
+
+ ///
+ /// Reads a plan from an XML file.
+ ///
+ /// The source file path.
+ /// The deserialized plan.
+ public static SongPlan Load(string filePath) {
+ using FileStream stream = new(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return Load(stream);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Creates the XML writer settings used for plan files.
+ ///
+ /// Writer settings that produce readable UTF-8 XML without a byte order mark.
+ private static XmlWriterSettings CreateWriterSettings() {
+ return new XmlWriterSettings {
+ Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
+ Indent = true,
+ IndentChars = " ",
+ CloseOutput = false,
+ };
+ }
+
+ ///
+ /// Creates the XML reader settings used for plan files.
+ ///
+ /// Reader settings that prohibit DTD processing and leave the input stream open.
+ private static XmlReaderSettings CreateReaderSettings() {
+ return new XmlReaderSettings {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ CloseInput = false,
+ };
+ }
+
+ #endregion
+}