using System.Collections; using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// The port forwards saved on a host, in the order the user added them. /// /// /// /// A dedicated type rather than a list, for the reason gives: a plain list on a /// record compares by reference, so every host would read as changed on every sync pass. /// /// /// Ordered and unique by id. Order is how the session sidebar lists them. Uniqueness is by /// rather than by value, keeping the first occurrence, because the id is /// what the merge keys on — two entries with one id would be one forward the merge could not tell apart. /// /// /// Invalid rules are not rejected at construction, for the reason every collection in this assembly gives: /// they arrive from another client's payload, and a throw here would fail the whole sync pass. /// catches them. /// /// public sealed class PortForwardList : IReadOnlyList, IEquatable { private readonly PortForwardRule[] rules; private readonly int hash; private PortForwardList(PortForwardRule[] rules) { this.rules = rules; hash = ComputeHash(rules); } /// No forwards. public static PortForwardList Empty { get; } = new([]); /// public int Count => rules.Length; /// public PortForwardRule this[int index] => rules[index]; /// Copies a sequence of rules, keeping order and the first rule with any given id. public static PortForwardList Create(IEnumerable rules) { ArgumentNullException.ThrowIfNull(rules); var kept = new List(); var seen = new HashSet(); foreach (var rule in rules) { if (rule is null) { throw new ArgumentException("A forward list cannot contain a null rule.", nameof(rules)); } if (seen.Add(rule.Id)) { kept.Add(rule); } } return kept.Count == 0 ? Empty : new PortForwardList([.. kept]); } /// /// The list as a map from id to rule, in list order, which is the shape /// takes. /// /// /// Built in list order for the reason is: the merge unions keys /// ancestor first, then local, then remote, so an ordered map is what keeps "existing forwards, then /// additions" without the merge ordering anything itself. Unlike a pinned path, a value here can change /// under its key, so a conflict is reachable: both machines editing the same forward differently. /// public IReadOnlyDictionary ToIdMap() { var map = new Dictionary(rules.Length); foreach (var rule in rules) { map[rule.Id] = rule; } return map; } /// public bool Equals(PortForwardList? other) { if (ReferenceEquals(this, other)) { return true; } return other is not null && other.hash == hash && rules.AsSpan().SequenceEqual(other.rules); } /// public override bool Equals(object? obj) => Equals(obj as PortForwardList); /// public override int GetHashCode() => hash; /// public IEnumerator GetEnumerator() => ((IEnumerable)rules).GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => rules.GetEnumerator(); /// Contents equality, tolerating nulls on either side. [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Equals(PortForwardList) is the named alternate.")] public static bool operator ==(PortForwardList? left, PortForwardList? right) => left is null ? right is null : left.Equals(right); /// Contents inequality. public static bool operator !=(PortForwardList? left, PortForwardList? right) => !(left == right); private static int ComputeHash(PortForwardRule[] rules) { // Order-sensitive, because order is kept. var accumulator = new HashCode(); accumulator.Add(rules.Length); foreach (var rule in rules) { accumulator.Add(rule); } return accumulator.ToHashCode(); } }