Skip to main content

lean_ctx/core/knowledge/
anchors.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Code anchors of knowledge facts (Hive plan v3 J2; C0 1.9 section 3,
3//! `CodeAnchorV1`).
4//!
5//! An anchor ties a fact to the code it is about: a repo-relative `path`, an
6//! optional `symbol` defined in that file, and the `digest` of exactly those
7//! bytes when the fact was written. The engine resolves anchors against the
8//! checkout on disk, never against a cached index, so a stale graph cannot
9//! report an old body as current:
10//!
11//! - `current`: the symbol (or file) still digests to the anchored bytes;
12//! - `changed`: it is still there with other bytes;
13//! - `moved`: the same bytes now live under another path (found through the
14//!   project's code graph);
15//! - `missing`: neither the symbol nor its bytes are found.
16//!
17//! A fact with any anchor that is not `current` is *to verify*: it is still
18//! recalled, marked, never presented as current knowledge.
19//!
20//! Digest (normalisation fixed by the contract's `anchor_digest` vectors):
21//! the UTF-8 bytes of the definition node (tree-sitter node bytes, without
22//! the doc comments or attributes before it) or of the whole file, with a
23//! leading BOM dropped, `\r\n` and `\r` as `\n`, and trailing spaces and tabs
24//! of every line removed; `sha256:` + lowercase hex.
25//!
26//! Sealed-safe: anchors are part of the fact. A sealed repo's facts travel
27//! only inside the encrypted content, so their paths and symbol names do too;
28//! only the state (`to_verify`) is ever plain metadata.
29
30use std::path::{Component, Path};
31
32use unicode_normalization::UnicodeNormalization as _;
33
34/// The contract's anchor (C0 1.9 `CodeAnchorV1`). The engine's symbol names
35/// are `name`, or `name#n` for the n-th definition of that name in a file,
36/// and slug paths for Markdown sections ([`markdown_sections`]).
37pub use lean_ctx_protocol::hive::anchor::{CodeAnchorV1 as CodeAnchor, MAX_ANCHORS};
38
39const MAX_LANG_BYTES: usize = 32;
40/// Files above this size are not anchored (or resolved): `missing`.
41const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
42
43/// Where an anchor stands in a checkout now.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum AnchorState {
46    Current,
47    Changed,
48    Moved { path: String },
49    Missing,
50}
51
52impl AnchorState {
53    /// The contract's reason for a fact to verify (`None` when current).
54    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
64/// The contract's limits on a fact's anchors (`validate_anchors`: at most
65/// [`MAX_ANCHORS`], each valid, sorted by path and symbol with the whole-file
66/// anchor first, no repeats), plus the engine's own: every path in NFC.
67pub 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
74/// Sort and de-duplicate anchors into their one encoding.
75pub 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
117/// A path as an anchor names it: relative to `root`, `/`-separated, NFC.
118/// `None` for a path outside `root` or with `..` components.
119pub 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
139/// `sha256:` + hex of the normalised bytes (C0 1.9 section 3: BOM dropped,
140/// CRLF/CR to LF, trailing spaces and tabs removed per line).
141pub 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
150/// The file at the anchor path, read inside `root` only: no symlink may lead
151/// out of the checkout, and oversized or unreadable files are `None`.
152fn 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
166/// The slug of a Markdown heading text: lowercase, letters and digits kept,
167/// runs of anything else as one `-`, none at the edges ("Release & Steps"
168/// → "release-steps"). Imported facts (K1) key sections by the same slugs.
169pub 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
184/// The ATX sections of a Markdown file as (slug path, byte range): a
185/// section runs from its heading to the next heading of the same or a
186/// higher level (or the end). The path joins the slugs of its parent
187/// headings with `/` ("release/steps"); headings inside fenced code are not
188/// headings. Repeated paths get `#n` from the second on.
189pub fn markdown_sections(content: &str) -> Vec<(String, std::ops::Range<usize>)> {
190    let mut headings: Vec<(usize, usize, String)> = Vec::new(); // (level, start, slug)
191    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; // indented code
209        }
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/// Each definition in `content` as (symbol, byte range): `name`, and
247/// `name#n` from the second definition of the same name on; Markdown files
248/// by their sections ([`markdown_sections`]).
249#[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
281/// The anchored bytes of `symbol` (or of the whole file) in `content`.
282pub 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
293/// Anchor `path` (and `symbol` in it) as it is in `root` now.
294pub 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
317/// Parse `path` or `path::symbol` as a writer names an anchor.
318pub 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
325/// Where `anchor` stands in `root` now.
326pub 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
342/// Another file of the project that holds the anchored bytes: for a symbol,
343/// a definition of the same name in the code graph; for a whole file, none
344/// (a moved file without a symbol is `missing`).
345fn 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
367/// The reasons a fact with `anchors` is to verify in `root` (empty: current).
368/// Anchors resolved past this many in one pass are skipped (each costs a
369/// read and a parse).
370pub const MAX_RESOLVED_PER_PASS: usize = 20_000;
371
372/// Resolves anchors that may come from elsewhere (Hive facts other engines
373/// wrote) without turning the checkout into an oracle: only files the
374/// engine may read for knowledge (source admission), and in a git checkout
375/// only tracked ones, are ever read. Any other path is `Missing` without a
376/// read, whether it exists or not, so a crafted anchor on `.env` learns
377/// neither its content nor its existence. Such an anchor (a moved file's old
378/// path is gone, so it is never readable) is only looked for elsewhere through
379/// the code graph, among readable files. Reads are memoised for the pass;
380/// past [`MAX_RESOLVED_PER_PASS`] an anchor has no state (`None`).
381pub 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    /// For a report that leaves the machine: tracked and admitted files.
391    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    /// For what this engine shows its own agents: admitted files.
402    pub fn local(root: &'a Path) -> Self {
403        Self {
404            tracked: None,
405            ..Self::for_report(root)
406        }
407    }
408
409    /// Whether `path` may be read for an anchor at all.
410    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    /// Where `anchor` stands now; `None` only past the per-pass cap.
430    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            // A move found through the index points at another file: that
455            // one must be readable too, or the move tells nothing.
456            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
464/// The checkout's tracked files (`git ls-files`), or `None` outside git.
465fn 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        // An anchor someone else wrote, with the right digest of the secret.
523        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        // Right or wrong guess, present or not: the same answer, no read.
536        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        // A tracked file that changes is still seen.
541        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    /// C0 1.9 section 3: a whole-file anchor captured from disk carries the
580    /// contract's reference digest for every vector.
581    #[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        // Edits elsewhere, and to the doc comment, leave it current.
609        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}