use std::path::{Component, Path};
use unicode_normalization::UnicodeNormalization as _;
pub use lean_ctx_protocol::hive::anchor::{CodeAnchorV1 as CodeAnchor, MAX_ANCHORS};
const MAX_LANG_BYTES: usize = 32;
const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnchorState {
Current,
Changed,
Moved { path: String },
Missing,
}
impl AnchorState {
pub fn reason(&self) -> Option<&'static str> {
match self {
Self::Current => None,
Self::Changed => Some("anchor_changed"),
Self::Moved { .. } => Some("anchor_moved"),
Self::Missing => Some("anchor_missing"),
}
}
}
pub fn validate_all(anchors: &[CodeAnchor]) -> Result<(), String> {
lean_ctx_protocol::hive::anchor::validate_anchors(anchors).map_err(|e| e.to_string())?;
anchors
.iter()
.try_for_each(|anchor| check_path(&anchor.path))
}
pub fn canonical(mut anchors: Vec<CodeAnchor>) -> Vec<CodeAnchor> {
anchors.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
anchors.dedup_by(|a, b| sort_key(a) == sort_key(b));
anchors
}
fn sort_key(anchor: &CodeAnchor) -> (&str, Option<&str>) {
(anchor.path.as_str(), anchor.symbol.as_deref())
}
#[cfg(test)]
fn is_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn check_path(path: &str) -> Result<(), String> {
let malformed = || Err(format!("anchor path {path:?} is malformed"));
if path.is_empty()
|| path.len() > lean_ctx_protocol::hive::anchor::MAX_ANCHOR_PATH_BYTES
|| path.starts_with('/')
|| path.starts_with("./")
|| path.ends_with('/')
|| path.contains('\\')
|| path.contains('\0')
|| path.nfc().collect::<String>() != path
{
return malformed();
}
if path
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return malformed();
}
Ok(())
}
pub fn anchor_path(root: &Path, path: &str) -> Option<String> {
let candidate = Path::new(path);
let relative = if candidate.is_absolute() {
candidate.strip_prefix(root).ok()?
} else {
candidate
};
let mut parts = Vec::new();
for component in relative.components() {
match component {
Component::Normal(part) => parts.push(part.to_str()?.nfc().collect::<String>()),
Component::CurDir => {}
_ => return None,
}
}
let joined = parts.join("/");
check_path(&joined).ok()?;
Some(joined)
}
pub use lean_ctx_protocol::hive::anchor::anchor_digest as digest;
fn extension(path: &str) -> &str {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
}
fn read_inside(root: &Path, path: &str) -> Option<Vec<u8>> {
check_path(path).ok()?;
let root = root.canonicalize().ok()?;
let full = root.join(path).canonicalize().ok()?;
if !full.starts_with(&root) {
return None;
}
let meta = std::fs::metadata(&full).ok()?;
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
return None;
}
std::fs::read(&full).ok()
}
pub fn heading_slug(text: &str) -> String {
let mut slug = String::new();
for c in text.trim().chars().flat_map(char::to_lowercase) {
if c.is_alphanumeric() {
slug.push(c);
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
while slug.ends_with('-') {
slug.pop();
}
slug
}
pub fn markdown_sections(content: &str) -> Vec<(String, std::ops::Range<usize>)> {
let mut headings: Vec<(usize, usize, String)> = Vec::new(); let mut fence: Option<&str> = None;
let mut offset = 0;
for line in content.split_inclusive('\n') {
let start = offset;
offset += line.len();
let trimmed = line.trim_start();
if let Some(open) = fence {
if trimmed.starts_with(open) {
fence = None;
}
continue;
}
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
fence = Some(&trimmed[..3]);
continue;
}
if line.len() - trimmed.len() > 3 {
continue; }
let level = trimmed.bytes().take_while(|b| *b == b'#').count();
let rest = &trimmed[level..];
if (1..=6).contains(&level) && (rest.starts_with(' ') || rest.trim().is_empty()) {
let text = rest.trim().trim_end_matches('#').trim();
headings.push((level, start, heading_slug(text)));
}
}
let mut sections = Vec::with_capacity(headings.len());
let mut stack: Vec<(usize, String)> = Vec::new();
let mut seen = std::collections::HashMap::<String, usize>::new();
for (i, (level, start, slug)) in headings.iter().enumerate() {
while stack.last().is_some_and(|(l, _)| l >= level) {
stack.pop();
}
stack.push((*level, slug.clone()));
let end = headings[i + 1..]
.iter()
.find(|(l, _, _)| l <= level)
.map_or(content.len(), |(_, s, _)| *s);
let path = stack
.iter()
.map(|(_, s)| s.as_str())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("/");
if path.is_empty() {
continue;
}
let n = seen.entry(path.clone()).or_insert(0);
*n += 1;
let symbol = if *n == 1 { path } else { format!("{path}#{n}") };
sections.push((symbol, *start..end));
}
sections
}
#[cfg(feature = "tree-sitter")]
fn symbols(content: &str, ext: &str) -> Vec<(String, std::ops::Range<usize>)> {
if matches!(ext, "md" | "markdown" | "mdx") {
return markdown_sections(content);
}
let Some(spans) = crate::core::signatures_ts::extract_spans_ts(content, ext) else {
return Vec::new();
};
let mut seen = std::collections::HashMap::<String, usize>::new();
spans
.into_iter()
.map(|(sig, range)| {
let n = seen.entry(sig.name.clone()).or_insert(0);
*n += 1;
let symbol = if *n == 1 {
sig.name
} else {
format!("{}#{n}", sig.name)
};
(symbol, range)
})
.collect()
}
#[cfg(not(feature = "tree-sitter"))]
fn symbols(content: &str, ext: &str) -> Vec<(String, std::ops::Range<usize>)> {
if matches!(ext, "md" | "markdown" | "mdx") {
return markdown_sections(content);
}
Vec::new()
}
pub fn anchored<'a>(content: &'a [u8], path: &str, symbol: Option<&str>) -> Option<&'a [u8]> {
let Some(symbol) = symbol else {
return Some(content);
};
let text = std::str::from_utf8(content).ok()?;
symbols(text, extension(path))
.into_iter()
.find(|(name, _)| name == symbol)
.map(|(_, range)| &content[range])
}
pub fn capture(root: &Path, path: &str, symbol: Option<&str>) -> Result<CodeAnchor, String> {
let path =
anchor_path(root, path).ok_or_else(|| format!("{path}: not a path inside the project"))?;
let content =
read_inside(root, &path).ok_or_else(|| format!("{path}: not readable in the project"))?;
let bytes = anchored(&content, &path, symbol)
.ok_or_else(|| format!("{path}: no definition named {}", symbol.unwrap_or_default()))?;
let ext = extension(&path);
let anchor = CodeAnchor {
path: path.clone(),
symbol: symbol.map(str::to_owned),
digest: digest(bytes),
lang: (!ext.is_empty() && ext.len() <= MAX_LANG_BYTES)
.then(|| ext.to_ascii_lowercase())
.filter(|l| {
l.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}),
};
validate_all(std::slice::from_ref(&anchor))?;
Ok(anchor)
}
pub fn parse_spec(spec: &str) -> (&str, Option<&str>) {
match spec.split_once("::") {
Some((path, symbol)) if !symbol.is_empty() => (path, Some(symbol)),
_ => (spec, None),
}
}
pub fn resolve(root: &Path, anchor: &CodeAnchor) -> AnchorState {
if let Some(content) = read_inside(root, &anchor.path)
&& let Some(bytes) = anchored(&content, &anchor.path, anchor.symbol.as_deref())
{
return if digest(bytes) == anchor.digest {
AnchorState::Current
} else {
AnchorState::Changed
};
}
match moved_to(root, anchor) {
Some(path) => AnchorState::Moved { path },
None => AnchorState::Missing,
}
}
fn moved_to(root: &Path, anchor: &CodeAnchor) -> Option<String> {
let symbol = anchor.symbol.as_deref()?;
let name = symbol.split_once('#').map_or(symbol, |(name, _)| name);
let index = crate::core::graph_index::ProjectIndex::load(root.to_str()?)?;
let mut files: Vec<&str> = index
.symbols
.values()
.filter(|entry| entry.name == name && entry.file != anchor.path)
.map(|entry| entry.file.as_str())
.collect();
files.sort_unstable();
files.dedup();
files.into_iter().find_map(|file| {
let content = read_inside(root, file)?;
let text = std::str::from_utf8(&content).ok()?;
symbols(text, extension(file))
.into_iter()
.any(|(_, range)| digest(&content[range]) == anchor.digest)
.then(|| file.to_owned())
})
}
pub const MAX_RESOLVED_PER_PASS: usize = 20_000;
pub struct GuardedResolver<'a> {
root: &'a Path,
tracked: Option<std::collections::HashSet<String>>,
admitted: std::collections::HashMap<String, bool>,
memo: std::collections::HashMap<(String, Option<String>, String), AnchorState>,
resolved: usize,
}
impl<'a> GuardedResolver<'a> {
pub fn for_report(root: &'a Path) -> Self {
Self {
root,
tracked: git_tracked(root),
admitted: std::collections::HashMap::new(),
memo: std::collections::HashMap::new(),
resolved: 0,
}
}
pub fn local(root: &'a Path) -> Self {
Self {
tracked: None,
..Self::for_report(root)
}
}
pub fn may_read(&mut self, path: &str) -> bool {
if self
.tracked
.as_ref()
.is_some_and(|tracked| !tracked.contains(path))
{
return false;
}
let root = self.root;
*self.admitted.entry(path.to_owned()).or_insert_with(|| {
crate::core::archive::bindings::sources_at(
&root.to_string_lossy(),
&[path],
"ctx_knowledge",
)
.is_some()
})
}
pub fn resolve(&mut self, anchor: &CodeAnchor) -> Option<AnchorState> {
if check_path(&anchor.path).is_err() {
return Some(AnchorState::Missing);
}
let readable = self.may_read(&anchor.path);
let key = (
anchor.path.clone(),
anchor.symbol.clone(),
anchor.digest.clone(),
);
if let Some(state) = self.memo.get(&key) {
return Some(state.clone());
}
if self.resolved >= MAX_RESOLVED_PER_PASS {
return None;
}
self.resolved += 1;
let found = if readable {
resolve(self.root, anchor)
} else {
moved_to(self.root, anchor)
.map_or(AnchorState::Missing, |path| AnchorState::Moved { path })
};
let state = match found {
AnchorState::Moved { path } if !self.may_read(&path) => AnchorState::Missing,
state => state,
};
self.memo.insert(key, state.clone());
Some(state)
}
}
fn git_tracked(root: &Path) -> Option<std::collections::HashSet<String>> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["ls-files", "-z"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(
out.stdout
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
.filter_map(|p| std::str::from_utf8(p).ok().map(str::to_owned))
.collect(),
)
}
pub fn review(root: &Path, anchors: &[CodeAnchor]) -> Vec<(CodeAnchor, AnchorState)> {
anchors
.iter()
.map(|anchor| (anchor.clone(), resolve(root, anchor)))
.filter(|(_, state)| *state != AnchorState::Current)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn project(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (path, content) in files {
let full = dir.path().join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, content).unwrap();
}
dir
}
#[test]
fn a_guarded_resolver_never_reads_an_unadmitted_or_untracked_file() {
let _iso = crate::core::data_dir::isolated_data_dir();
let dir = project(&[("src/a.md", "kept\n"), (".env", "API_KEY=abc\n")]);
let root = dir.path();
let git = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.unwrap()
};
git(&["init", "-q"]);
git(&["add", "src/a.md"]);
let tracked = capture(root, "src/a.md", None).unwrap();
let probe = CodeAnchor {
path: ".env".into(),
symbol: None,
digest: digest(b"API_KEY=abc\n"),
lang: None,
};
let wrong = CodeAnchor {
digest: digest(b"API_KEY=xyz\n"),
..probe.clone()
};
let mut guard = GuardedResolver::for_report(root);
assert_eq!(guard.resolve(&tracked), Some(AnchorState::Current));
assert_eq!(guard.resolve(&probe), Some(AnchorState::Missing));
assert_eq!(guard.resolve(&wrong), Some(AnchorState::Missing));
std::fs::remove_file(root.join(".env")).unwrap();
assert_eq!(guard.resolve(&probe), Some(AnchorState::Missing));
std::fs::write(root.join("src/a.md"), "changed\n").unwrap();
let mut fresh = GuardedResolver::for_report(root);
assert_eq!(fresh.resolve(&tracked), Some(AnchorState::Changed));
}
#[test]
fn the_digest_normalises_bom_line_ends_and_trailing_blanks() {
assert_eq!(
digest(b"\xEF\xBB\xBFfn a() {} \r\n}\t\r"),
digest(b"fn a() {}\n}\n")
);
assert_ne!(
digest(b" fn a()"),
digest(b"fn a()"),
"leading blanks count"
);
assert!(is_digest(&digest(b"")));
}
#[test]
fn whole_file_anchors_follow_the_bytes() {
let dir = project(&[("src/notes.md", "retry three times\n")]);
let anchor = capture(dir.path(), "src/notes.md", None).unwrap();
assert_eq!(anchor.path, "src/notes.md");
assert_eq!(anchor.lang.as_deref(), Some("md"));
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current);
std::fs::write(dir.path().join("src/notes.md"), "retry three times \r\n").unwrap();
assert_eq!(
resolve(dir.path(), &anchor),
AnchorState::Current,
"normalised"
);
std::fs::write(dir.path().join("src/notes.md"), "retry twice\n").unwrap();
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Changed);
std::fs::remove_file(dir.path().join("src/notes.md")).unwrap();
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Missing);
}
#[test]
fn file_anchors_reproduce_the_contract_digest_vectors() {
let vectors: serde_json::Value = serde_json::from_str(include_str!(
"../../../crates/lean-ctx-protocol/tests/fixtures/hive/anchor_digest.vectors.json"
))
.unwrap();
let cases = vectors["cases"].as_array().unwrap();
assert!(cases.len() >= 7);
for case in cases {
let name = case["name"].as_str().unwrap();
let input = hex::decode(case["input_hex"].as_str().unwrap()).unwrap();
let dir = project(&[]);
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/case.txt"), &input).unwrap();
let anchor = capture(dir.path(), "src/case.txt", None).unwrap();
assert_eq!(anchor.digest, case["digest"].as_str().unwrap(), "{name}");
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current, "{name}");
}
}
#[cfg(feature = "tree-sitter")]
#[test]
fn symbol_anchors_cover_only_their_definition() {
let source = "/// Retries a webhook.\nfn retry() -> u8 {\n 3\n}\n\nfn other() {}\n";
let dir = project(&[("src/hook.rs", source)]);
let anchor = capture(dir.path(), "src/hook.rs", Some("retry")).unwrap();
assert_eq!(anchor.digest, digest(b"fn retry() -> u8 {\n 3\n}"));
std::fs::write(
dir.path().join("src/hook.rs"),
"/// Retries a webhook, with care.\nfn retry() -> u8 {\n 3\n}\n\nfn other() { let _ = 1; }\n",
)
.unwrap();
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current);
std::fs::write(
dir.path().join("src/hook.rs"),
"fn retry() -> u8 {\n 5\n}\n",
)
.unwrap();
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Changed);
assert!(capture(dir.path(), "src/hook.rs", Some("absent")).is_err());
}
#[test]
fn paths_stay_inside_the_project_and_follow_the_contract() {
let dir = project(&[("a.rs", "fn a() {}\n")]);
assert!(capture(dir.path(), "../a.rs", None).is_err());
assert!(capture(dir.path(), "/etc/hosts", None).is_err());
assert_eq!(anchor_path(dir.path(), "./a.rs").as_deref(), Some("a.rs"));
#[cfg(unix)]
{
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret"), "x").unwrap();
std::os::unix::fs::symlink(outside.path().join("secret"), dir.path().join("link"))
.unwrap();
assert!(
capture(dir.path(), "link", None).is_err(),
"no symlink out of the checkout"
);
}
for bad in ["", "/a", "./a", "a/", "a//b", "a/../b", "a\\b"] {
assert!(check_path(bad).is_err(), "{bad:?}");
}
let nfd = "cafe\u{301}.md";
assert!(check_path(nfd).is_err(), "NFC only");
assert!(check_path(&nfd.nfc().collect::<String>()).is_ok());
}
#[test]
fn a_fact_carries_sorted_unique_valid_anchors() {
let a = |path: &str, symbol: Option<&str>| CodeAnchor {
path: path.into(),
symbol: symbol.map(str::to_owned),
digest: digest(b"x"),
lang: None,
};
let anchors = canonical(vec![
a("b.rs", None),
a("a.rs", Some("f")),
a("a.rs", None),
a("a.rs", None),
]);
assert_eq!(
anchors,
vec![a("a.rs", None), a("a.rs", Some("f")), a("b.rs", None)]
);
assert!(validate_all(&anchors).is_ok());
assert!(validate_all(&[a("b.rs", None), a("a.rs", None)]).is_err());
assert!(validate_all(&vec![a("a.rs", None); 2]).is_err());
assert!(
validate_all(
&(0..17)
.map(|i| a(&format!("f{i:02}.rs"), None))
.collect::<Vec<_>>()
)
.is_err()
);
let mut bad = a("a.rs", Some(" f"));
assert!(bad.validate().is_err());
bad.symbol = None;
bad.lang = Some("Rust".into());
assert!(bad.validate().is_err());
assert_eq!(parse_spec("src/a.rs::retry"), ("src/a.rs", Some("retry")));
assert_eq!(parse_spec("src/a.rs"), ("src/a.rs", None));
}
#[test]
fn markdown_sections_nest_by_level_and_skip_fenced_code() {
let doc = "# Release\nintro\n## Steps\n1. tag\n```sh\n# not a heading\n```\n## Steps\nagain\n# Notes & More\nend\n";
let sections = markdown_sections(doc);
let names: Vec<&str> = sections.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
["release", "release/steps", "release/steps#2", "notes-more"]
);
let span = |name: &str| {
let (_, range) = sections.iter().find(|(n, _)| n == name).unwrap();
&doc[range.clone()]
};
assert_eq!(
span("release/steps"),
"## Steps\n1. tag\n```sh\n# not a heading\n```\n"
);
assert_eq!(span("notes-more"), "# Notes & More\nend\n");
assert!(span("release").starts_with("# Release\n") && span("release").ends_with("again\n"));
assert_eq!(heading_slug(" Release & Steps! "), "release-steps");
assert_eq!(heading_slug("Grüße 2"), "grüße-2");
}
#[test]
fn a_markdown_section_anchor_ignores_edits_elsewhere() {
let dir = project(&[(
"docs/release.md",
"# Release\n## Steps\n1. tag\n## Notes\nx\n",
)]);
let anchor = capture(dir.path(), "docs/release.md", Some("release/steps")).unwrap();
std::fs::write(
dir.path().join("docs/release.md"),
"# Release\n## Steps\n1. tag\n## Notes\ny\n",
)
.unwrap();
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Current);
std::fs::write(
dir.path().join("docs/release.md"),
"# Release\n## Notes\ny\n",
)
.unwrap();
assert_eq!(resolve(dir.path(), &anchor), AnchorState::Missing);
}
}