diff --git a/Semantics.Strings/ISemanticString.cs b/Semantics.Strings/ISemanticString.cs index 1a96b4b..a63c8ac 100644 --- a/Semantics.Strings/ISemanticString.cs +++ b/Semantics.Strings/ISemanticString.cs @@ -27,8 +27,10 @@ public interface ISemanticString : IComparable, IComparable, IE /// /// Use this property when you need to interoperate with APIs that expect regular strings. /// The name "WeakString" emphasizes that this breaks the type safety guarantees. + /// The value is read-only through this interface: it is set when the semantic string is created, + /// which is where it is canonicalized and validated (#310). /// - public string WeakString { get; init; } + public string WeakString { get; } // System.String interface diff --git a/Semantics.Strings/SemanticString.cs b/Semantics.Strings/SemanticString.cs index 4f319c2..2e3562e 100644 --- a/Semantics.Strings/SemanticString.cs +++ b/Semantics.Strings/SemanticString.cs @@ -71,7 +71,7 @@ public TDest As() // ISemanticString implementation /// - /// Gets or initializes the underlying string value for interoperability with non-semantic string operations. + /// Gets the underlying string value for interoperability with non-semantic string operations. /// /// /// The raw string value contained within this semantic string. Defaults to . @@ -80,9 +80,12 @@ public TDest As() /// Use this property when you need to interoperate with APIs that expect regular strings. /// The name "WeakString" emphasizes that accessing this property breaks the type safety guarantees /// that semantic strings provide. Consider using implicit conversion to string instead when possible. - /// This property is init-only to support record initialization while maintaining immutability. + /// The init accessor is protected so that a value can only arrive through Create, which + /// canonicalizes and validates it. A public one let uuid with { WeakString = "not a uuid" } + /// build an instance that never passed either (#310). A derived type may still set it from its own + /// constructor. /// - public string WeakString { get; init; } = string.Empty; + public string WeakString { get; protected init; } = string.Empty; /// public int Length => WeakString.Length; @@ -635,6 +638,7 @@ private static TDest FromStringInternal(string? value) /// /// The init accessor as a delegate, looked up once per type rather than per value (#444). + /// The accessor is protected (#310), so the lookup asks for non-public accessors too. /// /// The semantic string type to set the value of. private static class WeakStringSetter @@ -642,7 +646,7 @@ private static class WeakStringSetter { internal static readonly Action Set = (Action)Delegate.CreateDelegate( typeof(Action), - Ensure.NotNull(typeof(TDest).GetProperty(name: nameof(WeakString))?.GetSetMethod())); + Ensure.NotNull(typeof(TDest).GetProperty(name: nameof(WeakString))?.GetSetMethod(nonPublic: true))); } /// diff --git a/Semantics.Test/WeakStringAccessTests.cs b/Semantics.Test/WeakStringAccessTests.cs new file mode 100644 index 0000000..0c92f42 --- /dev/null +++ b/Semantics.Test/WeakStringAccessTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test; + +using System; +using System.IO; +using System.Reflection; +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; +using ktsu.Semantics.Strings.Identifiers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// A semantic string's value only arrives through Create, which canonicalizes and validates it. +/// A public init accessor let uuid with { WeakString = "not a uuid" } skip both (#310). +/// +[TestClass] +public class WeakStringAccessTests +{ + [TestMethod] + public void WeakString_HasNoPublicSetter() + { + Type[] types = + [ + typeof(MySemanticString), + typeof(Uuid), + typeof(AbsoluteDirectoryPath), + typeof(AbsoluteFilePath), + typeof(RelativeDirectoryPath), + ]; + + foreach (Type type in types) + { + PropertyInfo property = type.GetProperty(nameof(ISemanticString.WeakString))!; + + Assert.IsNotNull(property.GetGetMethod(), $"{type.Name}.WeakString should stay publicly readable."); + Assert.IsNull(property.GetSetMethod(), $"{type.Name}.WeakString should not be publicly settable."); + } + } + + [TestMethod] + public void ISemanticString_WeakString_IsReadOnly() + { + PropertyInfo property = typeof(ISemanticString).GetProperty(nameof(ISemanticString.WeakString))!; + + Assert.IsFalse(property.CanWrite); + } + + [TestMethod] + public void Create_StillSetsTheValue() + { + const string value = "123e4567-e89b-12d3-a456-426614174000"; + + Assert.AreEqual(value, Uuid.Create(value).WeakString); + Assert.AreEqual("test", MySemanticString.Create("test").WeakString); + } + + [TestMethod] + public void Create_StillSetsThePathValue() + { + AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(Path.GetTempPath()); + AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(Path.Join(parent.WeakString, "weakstring")); + + Assert.AreEqual(parent, child.Parent); + } +}