Skip to main content

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}