namespace AnchorCli.Hashline; /// /// Validates and resolves Hashline anchors of the form "{lineNumber}:{hash}" against /// the current state of a file's lines. /// /// An anchor is valid if and only if BOTH conditions hold: /// 1. The referenced line number exists in the file. /// 2. The hash at that line number matches what the encoder would produce today. /// /// This dual-check makes stale edits fail loudly rather than silently corrupting a /// different line that happens to share the same hash. /// internal static class HashlineValidator { /// /// Parses a "{lineNumber}:{hash}" anchor string. /// /// True and the parsed values if the format is valid; false otherwise. public static bool TryParseAnchor(string anchor, out int lineNumber, out string hash) { lineNumber = 0; hash = string.Empty; int colonIndex = anchor.IndexOf(':'); if (colonIndex <= 0 || colonIndex == anchor.Length - 1) return false; ReadOnlySpan numPart = anchor.AsSpan(0, colonIndex); ReadOnlySpan hashPart = anchor.AsSpan(colonIndex + 1); if (!int.TryParse(numPart, out lineNumber) || lineNumber < 1) return false; hash = hashPart.ToString(); return hash.Length == 2; } /// /// Resolves an anchor to a 0-based index into , /// verifying both the line number and the content hash. /// /// Anchor string, e.g. "5:a3". /// Current file lines (without newlines). /// Resolved 0-based index on success. /// Human-readable error message on failure. /// True if the anchor is valid and current; false otherwise. public static bool TryResolve( string anchor, string[] lines, out int zeroBasedIndex, out string error) { zeroBasedIndex = -1; error = string.Empty; if (!TryParseAnchor(anchor, out int lineNumber, out string expectedHash)) { error = $"Anchor '{anchor}' is not in 'lineNumber:hash' format (e.g. '5:a3')."; return false; } if (lineNumber < 1 || lineNumber > lines.Length) { error = $"Anchor '{anchor}': line {lineNumber} is out of range " + $"(file has {lines.Length} line(s))."; return false; } string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber); if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) { error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " + $"(expected '{expectedHash}', got '{actualHash}'). " + $"The file has changed — re-read before editing."; return false; } zeroBasedIndex = lineNumber - 1; return true; } /// /// Validates both a start and end anchor, and ensures start <= end. /// /// The starting anchor string. /// The ending anchor string. /// Current file lines (without newlines). /// Resolved 0-based start index on success. /// Resolved 0-based end index on success. /// Human-readable error message on failure. /// True if the range is valid; false otherwise. public static bool TryResolveRange( string startAnchor, string endAnchor, string[] lines, out int startIndex, out int endIndex, out string error) { startIndex = -1; endIndex = -1; error = string.Empty; if (!TryResolve(startAnchor, lines, out startIndex, out error)) return false; if (!TryResolve(endAnchor, lines, out endIndex, out error)) return false; if (startIndex > endIndex) { error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " + $"is after end anchor '{endAnchor}' (line {endIndex + 1})."; return false; } return true; } }