lean_ctx/core/anchor.rs
1//! Hash-anchored line identifiers — the shared spine of anchored editing
2//! (epic #1008 / GL#1009).
3//!
4//! Each source line is tagged with a short content hash so an agent can edit
5//! "by reference" (line number + hash) instead of reproducing the exact old
6//! text byte-for-byte (the `str_replace` *exact-recall tax*). The anchor format
7//! is `N:hh|content` — `N` = 1-based line number, `hh` = the first
8//! [`ANCHOR_HASH_LEN`] hex chars of `blake3(trim_end(line))`.
9//!
10//! Two design choices make anchors safe and cheap:
11//!
12//! * **Whitespace-tolerant**: the hash is computed over the line with trailing
13//! whitespace trimmed, so re-indentation or a stray trailing space does not
14//! spuriously invalidate an anchor while still pinning the meaningful content.
15//! * **Determinism-native (#498)**: an anchor is a pure function of the line's
16//! bytes, so `ctx_read(mode="anchored")` is byte-stable across identical
17//! re-reads and provider prompt caching still applies.
18//!
19//! This module is the single source of truth for the hash + rendering so the
20//! read side ([`crate::tools::ctx_read`]) and the edit side
21//! ([`crate::tools::ctx_patch`]) can never compute anchors differently.
22
23use crate::core::hasher;
24
25/// Hex chars of the per-line BLAKE3 digest carried in an anchor.
26///
27/// 4 hex = 16 bits. Combined with the line number, a coincidental stale-line
28/// collision (a *different* line that happens to share both position and hash)
29/// is ~1/65536, while the token overhead stays at a few chars per line.
30pub const ANCHOR_HASH_LEN: usize = 4;
31
32/// The anchor hash of a single line.
33///
34/// Trailing whitespace is ignored so the hash pins meaningful content and stays
35/// stable across trivial trailing-whitespace churn. Returns lowercase hex.
36#[must_use]
37pub fn line_hash(line: &str) -> String {
38 let full = hasher::hash_hex(line.trim_end().as_bytes());
39 full[..ANCHOR_HASH_LEN].to_string()
40}
41
42/// Whether `provided` is the anchor hash of `line` (case-insensitive, trimmed).
43///
44/// The edit side calls this to detect staleness: if the line on disk no longer
45/// hashes to the anchor the model was given, the file drifted and the edit must
46/// be rejected rather than applied to the wrong content.
47#[must_use]
48pub fn hash_matches(line: &str, provided: &str) -> bool {
49 line_hash(line).eq_ignore_ascii_case(provided.trim())
50}
51
52/// Render `content` as anchored lines `N:hh|text`, numbering from `start_line`
53/// (1-based). Pure function of `(content, start_line)` for determinism (#498).
54///
55/// The returned string has no trailing newline, so callers control framing.
56/// Note: line splitting follows [`str::lines`] (a trailing newline does not
57/// yield an extra empty line), matching how the rest of `ctx_read` counts lines.
58#[must_use]
59pub fn annotate(content: &str, start_line: usize) -> String {
60 let mut out =
61 String::with_capacity(content.len() + content.lines().count() * (ANCHOR_HASH_LEN + 6));
62 for (i, line) in content.lines().enumerate() {
63 let n = start_line + i;
64 out.push_str(&n.to_string());
65 out.push(':');
66 out.push_str(&line_hash(line));
67 out.push('|');
68 out.push_str(line);
69 out.push('\n');
70 }
71 out.pop(); // drop the trailing '\n'; callers frame as needed
72 out
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn line_hash_is_short_and_lowercase_hex() {
81 let h = line_hash("let x = 1;");
82 assert_eq!(h.len(), ANCHOR_HASH_LEN);
83 assert!(
84 h.chars()
85 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
86 );
87 }
88
89 #[test]
90 fn line_hash_ignores_trailing_whitespace() {
91 assert_eq!(line_hash("foo"), line_hash("foo "));
92 assert_eq!(line_hash("foo\t"), line_hash("foo"));
93 }
94
95 #[test]
96 fn line_hash_is_sensitive_to_leading_whitespace() {
97 // Leading indentation IS meaningful (it can change scope/semantics), so
98 // it must affect the hash — only trailing whitespace is normalized.
99 assert_ne!(line_hash("foo"), line_hash(" foo"));
100 }
101
102 #[test]
103 fn hash_matches_is_case_insensitive_and_trimmed() {
104 let h = line_hash("bar");
105 assert!(hash_matches("bar", &h));
106 assert!(hash_matches("bar", &h.to_uppercase()));
107 assert!(hash_matches("bar", &format!(" {h} ")));
108 assert!(!hash_matches("baz", &h));
109 }
110
111 #[test]
112 fn annotate_numbers_from_start_line() {
113 let out = annotate("a\nb\nc", 1);
114 let lines: Vec<&str> = out.lines().collect();
115 assert_eq!(lines.len(), 3);
116 assert!(lines[0].starts_with("1:"));
117 assert!(lines[1].starts_with("2:"));
118 assert!(lines[2].starts_with("3:"));
119 assert!(lines[0].ends_with("|a"));
120 }
121
122 #[test]
123 fn annotate_respects_custom_start_line() {
124 let out = annotate("x\ny", 10);
125 assert!(out.lines().next().unwrap().starts_with("10:"));
126 assert!(out.lines().nth(1).unwrap().starts_with("11:"));
127 }
128
129 #[test]
130 fn annotate_format_is_parseable() {
131 // Format contract relied on by ctx_patch's anchor parser: `N:hh|content`.
132 let out = annotate("hello world", 1);
133 let (prefix, body) = out.split_once('|').unwrap();
134 assert_eq!(body, "hello world");
135 let (n, h) = prefix.split_once(':').unwrap();
136 assert_eq!(n, "1");
137 assert_eq!(h, line_hash("hello world"));
138 }
139
140 #[test]
141 fn annotate_empty_content_is_empty() {
142 assert_eq!(annotate("", 1), "");
143 }
144
145 #[test]
146 fn annotate_is_deterministic() {
147 let content = "fn main() {\n println!(\"hi\");\n}";
148 assert_eq!(annotate(content, 1), annotate(content, 1));
149 }
150
151 #[test]
152 fn annotate_preserves_blank_lines() {
153 let out = annotate("a\n\nb", 1);
154 let lines: Vec<&str> = out.lines().collect();
155 assert_eq!(lines.len(), 3);
156 assert!(lines[1].starts_with("2:"));
157 assert!(lines[1].ends_with('|'), "blank line keeps an empty body");
158 }
159}