1use std::path::{Component, Path};
31
32use unicode_normalization::UnicodeNormalization as _;
33
34pub use lean_ctx_protocol::hive::anchor::{CodeAnchorV1 as CodeAnchor, MAX_ANCHORS};
38
39const MAX_LANG_BYTES: usize = 32;
40const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum AnchorState {
46 Current,
47 Changed,
48 Moved { path: String },
49 Missing,
50}
51
52impl AnchorState {
53 pub fn reason(&self) -> Option<&'static str> {
55 match self {
56 Self::Current => None,
57 Self::Changed => Some("anchor_changed"),
58 Self::Moved { .. } => Some("anchor_moved"),
59 Self::Missing => Some("anchor_missing"),
60 }
61 }
62}
63
64pub fn validate_all(anchors: &[CodeAnchor]) -> Result<(), String> {
68 lean_ctx_protocol::hive::anchor::validate_anchors(anchors).map_err(|e| e.to_string())?;
69 anchors
70 .iter()
71 .try_for_each(|anchor| check_path(&anchor.path))
72}
73
74pub fn canonical(mut anchors: Vec<CodeAnchor>) -> Vec<CodeAnchor> {
76 anchors.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
77 anchors.dedup_by(|a, b| sort_key(a) == sort_key(b));
78 anchors
79}
80
81fn sort_key(anchor: &CodeAnchor) -> (&str, Option<&str>) {
82 (anchor.path.as_str(), anchor.symbol.as_deref())
83}
84
85#[cfg(test)]
86fn is_digest(value: &str) -> bool {
87 value.strip_prefix("sha256:").is_some_and(|hex| {
88 hex.len() == 64
89 && hex
90 .bytes()
91 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
92 })
93}
94
95fn check_path(path: &str) -> Result<(), String> {
96 let malformed = || Err(format!("anchor path {path:?} is malformed"));
97 if path.is_empty()
98 || path.len() > lean_ctx_protocol::hive::anchor::MAX_ANCHOR_PATH_BYTES
99 || path.starts_with('/')
100 || path.starts_with("./")
101 || path.ends_with('/')
102 || path.contains('\\')
103 || path.contains('\0')
104 || path.nfc().collect::<String>() != path
105 {
106 return malformed();
107 }
108 if path
109 .split('/')
110 .any(|segment| segment.is_empty() || segment == "." || segment == "..")
111 {
112 return malformed();
113 }
114 Ok(())
115}
116
117pub fn anchor_path(root: &Path, path: &str) -> Option<String> {
120 let candidate = Path::new(path);
121 let relative = if candidate.is_absolute() {
122 candidate.strip_prefix(root).ok()?
123 } else {
124 candidate
125 };
126 let mut parts = Vec::new();
127 for component in relative.components() {
128 match component {
129 Component::Normal(part) => parts.push(part.to_str()?.nfc().collect::<String>()),
130 Component::CurDir => {}
131 _ => return None,
132 }
133 }
134 let joined = parts.join("/");
135 check_path(&joined).ok()?;
136 Some(joined)
137}
138
139pub use lean_ctx_protocol::hive::anchor::anchor_digest as digest;
142
143fn extension(path: &str) -> &str {
144 Path::new(path)
145 .extension()
146 .and_then(|e| e.to_str())
147 .unwrap_or("")
148}
149
150fn read_inside(root: &Path, path: &str) -> Option<Vec<u8>> {
153 check_path(path).ok()?;
154 let root = root.canonicalize().ok()?;
155 let full = root.join(path).canonicalize().ok()?;
156 if !full.starts_with(&root) {
157 return None;
158 }
159 let meta = std::fs::metadata(&full).ok()?;
160 if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
161 return None;
162 }
163 std::fs::read(&full).ok()
164}
165
166pub fn heading_slug(text: &str) -> String {
170 let mut slug = String::new();
171 for c in text.trim().chars().flat_map(char::to_lowercase) {
172 if c.is_alphanumeric() {
173 slug.push(c);
174 } else if !slug.is_empty() && !slug.ends_with('-') {
175 slug.push('-');
176 }
177 }
178 while slug.ends_with('-') {
179 slug.pop();
180 }
181 slug
182}
183
184pub fn markdown_sections(content: &str) -> Vec<(String, std::ops::Range<usize>)> {
190 let mut headings: Vec<(usize, usize, String)> = Vec::new(); let mut fence: Option<&str> = None;
192 let mut offset = 0;
193 for line in content.split_inclusive('\n') {
194 let start = offset;
195 offset += line.len();
196 let trimmed = line.trim_start();
197 if let Some(open) = fence {
198 if trimmed.starts_with(open) {
199 fence = None;
200 }
201 continue;
202 }
203 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
204 fence = Some(&trimmed[..3]);
205 continue;
206 }
207 if line.len() - trimmed.len() > 3 {
208 continue; }
210 let level = trimmed.bytes().take_while(|b| *b == b'#').count();
211 let rest = &trimmed[level..];
212 if (1..=6).contains(&level) && (rest.starts_with(' ') || rest.trim().is_empty()) {
213 let text = rest.trim().trim_end_matches('#').trim();
214 headings.push((level, start, heading_slug(text)));
215 }
216 }
217 let mut sections = Vec::with_capacity(headings.len());
218 let mut stack: Vec<(usize, String)> = Vec::new();
219 let mut seen = std::collections::HashMap::<String, usize>::new();
220 for (i, (level, start, slug)) in headings.iter().enumerate() {
221 while stack.last().is_some_and(|(l, _)| l >= level) {
222 stack.pop();
223 }
224 stack.push((*level, slug.clone()));
225 let end = headings[i + 1..]
226 .iter()
227 .find(|(l, _, _)| l <= level)
228 .map_or(content.len(), |(_, s, _)| *s);
229 let path = stack
230 .iter()
231 .map(|(_, s)| s.as_str())
232 .filter(|s| !s.is_empty())
233 .collect::<Vec<_>>()
234 .join("/");
235 if path.is_empty() {
236 continue;
237 }
238 let n = seen.entry(path.clone()).or_insert(0);
239 *n += 1;
240 let symbol = if *n == 1 { path } else { format!("{path}#{n}") };
241 sections.push((symbol, *start..end));
242 }
243 sections
244}
245
246#[cfg(feature = "tree-sitter")]
250fn symbols(content: &str, ext: &str) -> Vec<(String, std::ops::Range<usize>)> {
251 if matches!(ext, "md" | "markdown" | "mdx") {
252 return markdown_sections(content);
253 }
254 let Some(spans) = crate::core::signatures_ts::extract_spans_ts(content, ext) else {
255 return Vec::new();
256 };
257 let mut seen = std::collections::HashMap::<String, usize>::new();
258 spans
259 .into_iter()
260 .map(|(sig, range)| {
261 let n = seen.entry(sig.name.clone()).or_insert(0);
262 *n += 1;
263 let symbol = if *n == 1 {
264 sig.name
265 } else {
266 format!("{}#{n}", sig.name)
267 };
268 (symbol, range)
269 })
270 .collect()
271}
272
273#[cfg(not(feature = "tree-sitter"))]
274fn symbols(content: &str, ext: &str) -> Vec<(String, std::ops::Range<usize>)> {
275 if matches!(ext, "md" | "markdown" | "mdx") {
276 return markdown_sections(content);
277 }
278 Vec::new()
279}
280
281pub fn anchored<'a>(content: &'a [u8], path: &str, symbol: Option<&str>) -> Option<&'a [u8]> {
283 let Some(symbol) = symbol else {
284 return Some(content);
285 };
286 let text = std::str::from_utf8(content).ok()?;
287 symbols(text, extension(path))
288 .into_iter()
289 .find(|(name, _)| name == symbol)
290 .map(|(_, range)| &content[range])
291}
292
293pub fn capture(root: &Path, path: &str, symbol: Option<&str>) -> Result<CodeAnchor, String> {
295 let path =
296 anchor_path(root, path).ok_or_else(|| format!("{path}: not a path inside the project"))?;
297 let content =
298 read_inside(root, &path).ok_or_else(|| format!("{path}: not readable in the project"))?;
299 let bytes = anchored(&content, &path, symbol)
300 .ok_or_else(|| format!("{path}: no definition named {}", symbol.unwrap_or_default()))?;
301 let ext = extension(&path);
302 let anchor = CodeAnchor {
303 path: path.clone(),
304 symbol: symbol.map(str::to_owned),
305 digest: digest(bytes),
306 lang: (!ext.is_empty() && ext.len() <= MAX_LANG_BYTES)
307 .then(|| ext.to_ascii_lowercase())
308 .filter(|l| {
309 l.bytes()
310 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
311 }),
312 };
313 validate_all(std::slice::from_ref(&anchor))?;
314 Ok(anchor)
315}
316
317pub fn parse_spec(spec: &str) -> (&str, Option<&str>) {
319 match spec.split_once("::") {
320 Some((path, symbol)) if !symbol.is_empty() => (path, Some(symbol)),
321 _ => (spec, None),
322 }
323}
324
325pub fn resolve(root: &Path, anchor: &CodeAnchor) -> AnchorState {
327 if let Some(content) = read_inside(root, &anchor.path)
328 && let Some(bytes) = anchored(&content, &anchor.path, anchor.symbol.as_deref())
329 {
330 return if digest(bytes) == anchor.digest {
331 AnchorState::Current
332 } else {
333 AnchorState::Changed
334 };
335 }
336 match moved_to(root, anchor) {
337 Some(path) => AnchorState::Moved { path },
338 None => AnchorState::Missing,
339 }
340}
341
342fn moved_to(root: &Path, anchor: &CodeAnchor) -> Option<String> {
346 let symbol = anchor.symbol.as_deref()?;
347 let name = symbol.split_once('#').map_or(symbol, |(name, _)| name);
348 let index = crate::core::graph_index::ProjectIndex::load(root.to_str()?)?;
349 let mut files: Vec<&str> = index
350 .symbols
351 .values()
352 .filter(|entry| entry.name == name && entry.file != anchor.path)
353 .map(|entry| entry.file.as_str())
354 .collect();
355 files.sort_unstable();
356 files.dedup();
357 files.into_iter().find_map(|file| {
358 let content = read_inside(root, file)?;
359 let text = std::str::from_utf8(&content).ok()?;
360 symbols(text, extension(file))
361 .into_iter()
362 .any(|(_, range)| digest(&content[range]) == anchor.digest)
363 .then(|| file.to_owned())
364 })
365}
366
367pub const MAX_RESOLVED_PER_PASS: usize = 20_000;
371
372pub struct GuardedResolver<'a> {
382 root: &'a Path,
383 tracked: Option<std::collections::HashSet<String>>,
384 admitted: std::collections::HashMap<String, bool>,
385 memo: std::collections::HashMap<(String, Option<String>, String), AnchorState>,
386 resolved: usize,
387}
388
389impl<'a> GuardedResolver<'a> {
390 pub fn for_report(root: &'a Path) -> Self {
392 Self {
393 root,
394 tracked: git_tracked(root),
395 admitted: std::collections::HashMap::new(),
396 memo: std::collections::HashMap::new(),
397 resolved: 0,
398 }
399 }
400
401 pub fn local(root: &'a Path) -> Self {
403 Self {
404 tracked: None,
405 ..Self::for_report(root)
406 }
407 }
408
409 pub fn may_read(&mut self, path: &str) -> bool {
411 if self
412 .tracked
413 .as_ref()
414 .is_some_and(|tracked| !tracked.contains(path))
415 {
416 return false;
417 }
418 let root = self.root;
419 *self.admitted.entry(path.to_owned()).or_insert_with(|| {
420 crate::core::archive::bindings::sources_at(
421 &root.to_string_lossy(),
422 &[path],
423 "ctx_knowledge",
424 )
425 .is_some()
426 })
427 }
428
429 pub fn resolve(&mut self, anchor: &CodeAnchor) -> Option<AnchorState> {
431 if check_path(&anchor.path).is_err() {
432 return Some(AnchorState::Missing);
433 }
434 let readable = self.may_read(&anchor.path);
435 let key = (
436 anchor.path.clone(),
437 anchor.symbol.clone(),
438 anchor.digest.clone(),
439 );
440 if let Some(state) = self.memo.get(&key) {
441 return Some(state.clone());
442 }
443 if self.resolved >= MAX_RESOLVED_PER_PASS {
444 return None;
445 }
446 self.resolved += 1;
447 let found = if readable {
448 resolve(self.root, anchor)
449 } else {
450 moved_to(self.root, anchor)
451 .map_or(AnchorState::Missing, |path| AnchorState::Moved { path })
452 };
453 let state = match found {
454 AnchorState::Moved { path } if !self.may_read(&path) => AnchorState::Missing,
457 state => state,
458 };
459 self.memo.insert(key, state.clone());
460 Some(state)
461 }
462}
463
464fn git_tracked(root: &Path) -> Option<std::collections::HashSet<String>> {
466 let out = std::process::Command::new("git")
467 .arg("-C")
468 .arg(root)
469 .args(["ls-files", "-z"])
470 .output()
471 .ok()?;
472 if !out.status.success() {
473 return None;
474 }
475 Some(
476 out.stdout
477 .split(|b| *b == 0)
478 .filter(|p| !p.is_empty())
479 .filter_map(|p| std::str::from_utf8(p).ok().map(str::to_owned))
480 .collect(),
481 )
482}
483
484pub fn review(root: &Path, anchors: &[CodeAnchor]) -> Vec<(CodeAnchor, AnchorState)> {
485 anchors
486 .iter()
487 .map(|anchor| (anchor.clone(), resolve(root, anchor)))
488 .filter(|(_, state)| *state != AnchorState::Current)
489 .collect()
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn project(files: &[(&str, &str)]) -> tempfile::TempDir {
497 let dir = tempfile::tempdir().unwrap();
498 for (path, content) in files {
499 let full = dir.path().join(path);
500 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
501 std::fs::write(full, content).unwrap();
502 }
503 dir
504 }
505
506 #[test]
507 fn a_guarded_resolver_never_reads_an_unadmitted_or_untracked_file() {
508 let _iso = crate::core::data_dir::isolated_data_dir();
509 let dir = project(&[("src/a.md", "kept\n"), (".env", "API_KEY=abc\n")]);
510 let root = dir.path();
511 let git = |args: &[&str]| {
512 std::process::Command::new("git")
513 .arg("-C")
514 .arg(root)
515 .args(args)
516 .output()
517 .unwrap()
518 };
519 git(&["init", "-q"]);
520 git(&["add", "src/a.md"]);
521 let tracked = capture(root, "src/a.md", None).unwrap();
522 let probe = CodeAnchor {
524 path: ".env".into(),
525 symbol: None,
526 digest: digest(b"API_KEY=abc\n"),
527 lang: None,
528 };
529 let wrong = CodeAnchor {
530 digest: digest(b"API_KEY=xyz\n"),
531 ..probe.clone()
532 };
533 let mut guard = GuardedResolver::for_report(root);
534 assert_eq!(guard.resolve(&tracked), Some(AnchorState::Current));
535 assert_eq!(guard.resolve(&probe), Some(AnchorState::Missing));
537 assert_eq!(guard.resolve(&wrong), Some(AnchorState::Missing));
538 std::fs::remove_file(root.join(".env")).unwrap();
539 assert_eq!(guard.resolve(&probe), Some(AnchorState::Missing));
540 std::fs::write(root.join("src/a.md"), "changed\n").unwrap();
542 let mut fresh = GuardedResolver::for_report(root);
543 assert_eq!(fresh.resolve(&tracked), Some(AnchorState::Changed));
544 }
545
546 #[test]
547 fn the_digest_normalises_bom_line_ends_and_trailing_blanks() {
548 assert_eq!(
549 digest(b"\xEF\xBB\xBFfn a() {} \r\n}\t\r"),
550 digest(b"fn a() {}\n}\n")
551 );
552 assert_ne!(
553 digest(b" fn a()"),
554 digest(b"fn a()"),
555 "leading blanks count"
556 );
557 assert!(is_digest(&digest(b"")));
558 }
559
560 #[test]
561 fn whole_file_anchors_follow_the_bytes() {
562 let dir = project(&[("src/notes.md", "retry three times\n")]);
563 let anchor = capture(dir.path(), "src/notes.md", None).unwrap();
564 assert_eq!(anchor.path, "src/notes.md");
565 assert_eq!(anchor.lang.as_deref(), Some("md"));
566 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current);
567 std::fs::write(dir.path().join("src/notes.md"), "retry three times \r\n").unwrap();
568 assert_eq!(
569 resolve(dir.path(), &anchor),
570 AnchorState::Current,
571 "normalised"
572 );
573 std::fs::write(dir.path().join("src/notes.md"), "retry twice\n").unwrap();
574 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Changed);
575 std::fs::remove_file(dir.path().join("src/notes.md")).unwrap();
576 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Missing);
577 }
578
579 #[test]
582 fn file_anchors_reproduce_the_contract_digest_vectors() {
583 let vectors: serde_json::Value = serde_json::from_str(include_str!(
584 "../../../crates/lean-ctx-protocol/tests/fixtures/hive/anchor_digest.vectors.json"
585 ))
586 .unwrap();
587 let cases = vectors["cases"].as_array().unwrap();
588 assert!(cases.len() >= 7);
589 for case in cases {
590 let name = case["name"].as_str().unwrap();
591 let input = hex::decode(case["input_hex"].as_str().unwrap()).unwrap();
592 let dir = project(&[]);
593 std::fs::create_dir_all(dir.path().join("src")).unwrap();
594 std::fs::write(dir.path().join("src/case.txt"), &input).unwrap();
595 let anchor = capture(dir.path(), "src/case.txt", None).unwrap();
596 assert_eq!(anchor.digest, case["digest"].as_str().unwrap(), "{name}");
597 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current, "{name}");
598 }
599 }
600
601 #[cfg(feature = "tree-sitter")]
602 #[test]
603 fn symbol_anchors_cover_only_their_definition() {
604 let source = "/// Retries a webhook.\nfn retry() -> u8 {\n 3\n}\n\nfn other() {}\n";
605 let dir = project(&[("src/hook.rs", source)]);
606 let anchor = capture(dir.path(), "src/hook.rs", Some("retry")).unwrap();
607 assert_eq!(anchor.digest, digest(b"fn retry() -> u8 {\n 3\n}"));
608 std::fs::write(
610 dir.path().join("src/hook.rs"),
611 "/// Retries a webhook, with care.\nfn retry() -> u8 {\n 3\n}\n\nfn other() { let _ = 1; }\n",
612 )
613 .unwrap();
614 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current);
615 std::fs::write(
616 dir.path().join("src/hook.rs"),
617 "fn retry() -> u8 {\n 5\n}\n",
618 )
619 .unwrap();
620 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Changed);
621 assert!(capture(dir.path(), "src/hook.rs", Some("absent")).is_err());
622 }
623
624 #[test]
625 fn paths_stay_inside_the_project_and_follow_the_contract() {
626 let dir = project(&[("a.rs", "fn a() {}\n")]);
627 assert!(capture(dir.path(), "../a.rs", None).is_err());
628 assert!(capture(dir.path(), "/etc/hosts", None).is_err());
629 assert_eq!(anchor_path(dir.path(), "./a.rs").as_deref(), Some("a.rs"));
630 #[cfg(unix)]
631 {
632 let outside = tempfile::tempdir().unwrap();
633 std::fs::write(outside.path().join("secret"), "x").unwrap();
634 std::os::unix::fs::symlink(outside.path().join("secret"), dir.path().join("link"))
635 .unwrap();
636 assert!(
637 capture(dir.path(), "link", None).is_err(),
638 "no symlink out of the checkout"
639 );
640 }
641 for bad in ["", "/a", "./a", "a/", "a//b", "a/../b", "a\\b"] {
642 assert!(check_path(bad).is_err(), "{bad:?}");
643 }
644 let nfd = "cafe\u{301}.md";
645 assert!(check_path(nfd).is_err(), "NFC only");
646 assert!(check_path(&nfd.nfc().collect::<String>()).is_ok());
647 }
648
649 #[test]
650 fn a_fact_carries_sorted_unique_valid_anchors() {
651 let a = |path: &str, symbol: Option<&str>| CodeAnchor {
652 path: path.into(),
653 symbol: symbol.map(str::to_owned),
654 digest: digest(b"x"),
655 lang: None,
656 };
657 let anchors = canonical(vec![
658 a("b.rs", None),
659 a("a.rs", Some("f")),
660 a("a.rs", None),
661 a("a.rs", None),
662 ]);
663 assert_eq!(
664 anchors,
665 vec![a("a.rs", None), a("a.rs", Some("f")), a("b.rs", None)]
666 );
667 assert!(validate_all(&anchors).is_ok());
668 assert!(validate_all(&[a("b.rs", None), a("a.rs", None)]).is_err());
669 assert!(validate_all(&vec![a("a.rs", None); 2]).is_err());
670 assert!(
671 validate_all(
672 &(0..17)
673 .map(|i| a(&format!("f{i:02}.rs"), None))
674 .collect::<Vec<_>>()
675 )
676 .is_err()
677 );
678 let mut bad = a("a.rs", Some(" f"));
679 assert!(bad.validate().is_err());
680 bad.symbol = None;
681 bad.lang = Some("Rust".into());
682 assert!(bad.validate().is_err());
683 assert_eq!(parse_spec("src/a.rs::retry"), ("src/a.rs", Some("retry")));
684 assert_eq!(parse_spec("src/a.rs"), ("src/a.rs", None));
685 }
686
687 #[test]
688 fn markdown_sections_nest_by_level_and_skip_fenced_code() {
689 let doc = "# Release\nintro\n## Steps\n1. tag\n```sh\n# not a heading\n```\n## Steps\nagain\n# Notes & More\nend\n";
690 let sections = markdown_sections(doc);
691 let names: Vec<&str> = sections.iter().map(|(n, _)| n.as_str()).collect();
692 assert_eq!(
693 names,
694 ["release", "release/steps", "release/steps#2", "notes-more"]
695 );
696 let span = |name: &str| {
697 let (_, range) = sections.iter().find(|(n, _)| n == name).unwrap();
698 &doc[range.clone()]
699 };
700 assert_eq!(
701 span("release/steps"),
702 "## Steps\n1. tag\n```sh\n# not a heading\n```\n"
703 );
704 assert_eq!(span("notes-more"), "# Notes & More\nend\n");
705 assert!(span("release").starts_with("# Release\n") && span("release").ends_with("again\n"));
706 assert_eq!(heading_slug(" Release & Steps! "), "release-steps");
707 assert_eq!(heading_slug("Grüße 2"), "grüße-2");
708 }
709
710 #[test]
711 fn a_markdown_section_anchor_ignores_edits_elsewhere() {
712 let dir = project(&[(
713 "docs/release.md",
714 "# Release\n## Steps\n1. tag\n## Notes\nx\n",
715 )]);
716 let anchor = capture(dir.path(), "docs/release.md", Some("release/steps")).unwrap();
717 std::fs::write(
718 dir.path().join("docs/release.md"),
719 "# Release\n## Steps\n1. tag\n## Notes\ny\n",
720 )
721 .unwrap();
722 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current);
723 std::fs::write(
724 dir.path().join("docs/release.md"),
725 "# Release\n## Notes\ny\n",
726 )
727 .unwrap();
728 assert_eq!(resolve(dir.path(), &anchor), AnchorState::Missing);
729 }
730}