Skip to main content

heddle_object_model/object/
staleness_core.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure staleness checks for annotation source hashes.
3
4use std::path::Path;
5
6use super::{Annotation, AnnotationScope, ContentHash};
7
8/// Result of checking an annotation's freshness against current code.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum StalenessStatus {
11    /// Source hash matches -- annotation is current.
12    Fresh,
13    /// Source at the annotated scope has changed since the annotation was written.
14    SourceChanged {
15        old_hash: ContentHash,
16        new_hash: ContentHash,
17    },
18    /// The file referenced by the annotation no longer exists in the tree.
19    FileMissing,
20    /// Symbol referenced by annotation no longer exists in the file.
21    SymbolMissing { symbol: String },
22    /// More than one definition matches the annotation's symbol selector, so
23    /// it no longer identifies one piece of source. The authored selector is
24    /// kept; `candidate_lines` are the 1-indexed inclusive matches.
25    SymbolAmbiguous {
26        symbol: String,
27        candidate_lines: Vec<(u32, u32)>,
28    },
29    /// More than one file passed the rename confidence threshold.
30    AmbiguousFileMove { candidate_paths: Vec<String> },
31    /// No provenance data stored -- staleness cannot be determined.
32    Unknown,
33}
34
35/// How a symbol selector resolved against current source.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum SymbolResolution {
38    /// Exactly one definition matches; 1-indexed inclusive lines.
39    Resolved { start: u32, end: u32 },
40    /// No definition matches.
41    Missing,
42    /// More than one definition matches.
43    Ambiguous { candidate_lines: Vec<(u32, u32)> },
44}
45
46/// Check an annotation's staleness against already-loaded source bytes.
47pub fn annotation_status_for_source(
48    annotation: &Annotation,
49    scope: &AnnotationScope,
50    source: &[u8],
51    file_path: &Path,
52) -> StalenessStatus {
53    annotation_status_for_source_with_symbol_resolver(
54        annotation,
55        scope,
56        source,
57        file_path,
58        resolve_current_symbol,
59    )
60}
61
62/// Check an annotation's staleness with an injected symbol resolver.
63pub fn annotation_status_for_source_with_symbol_resolver(
64    annotation: &Annotation,
65    scope: &AnnotationScope,
66    source: &[u8],
67    file_path: &Path,
68    mut resolve_symbol: impl FnMut(&[u8], &Path, &str, Option<(u32, u32)>) -> SymbolResolution,
69) -> StalenessStatus {
70    let Some(revision) = annotation.current_revision() else {
71        return StalenessStatus::Unknown;
72    };
73    let expected_hash = match &revision.source_hash {
74        Some(h) => h,
75        None => return StalenessStatus::Unknown,
76    };
77
78    let scoped_bytes = match scope {
79        AnnotationScope::File => source.to_vec(),
80        AnnotationScope::Lines(start, end) => extract_line_range(source, *start, *end),
81        AnnotationScope::Symbol {
82            name,
83            resolved_lines,
84        } => match resolve_symbol(source, file_path, name, *resolved_lines) {
85            SymbolResolution::Resolved { start, end } => extract_line_range(source, start, end),
86            SymbolResolution::Missing => {
87                return StalenessStatus::SymbolMissing {
88                    symbol: name.clone(),
89                };
90            }
91            SymbolResolution::Ambiguous { candidate_lines } => {
92                return StalenessStatus::SymbolAmbiguous {
93                    symbol: name.clone(),
94                    candidate_lines,
95                };
96            }
97        },
98    };
99
100    let current_hash = ContentHash::compute(&scoped_bytes);
101    if current_hash == *expected_hash {
102        StalenessStatus::Fresh
103    } else {
104        StalenessStatus::SourceChanged {
105            old_hash: *expected_hash,
106            new_hash: current_hash,
107        }
108    }
109}
110
111/// Extract bytes for a line range from source content.
112///
113/// Lines are 1-indexed. Returns the bytes spanning `start..=end` lines
114/// (inclusive on both ends), joined with newlines.
115pub fn extract_line_range(source: &[u8], start: u32, end: u32) -> Vec<u8> {
116    let text = std::str::from_utf8(source).unwrap_or("");
117    let lines: Vec<&str> = text.lines().collect();
118    let start_idx = (start as usize).saturating_sub(1);
119    let end_idx = (end as usize).min(lines.len());
120    if start_idx >= end_idx {
121        return Vec::new();
122    }
123    lines[start_idx..end_idx].join("\n").into_bytes()
124}
125
126/// Resolve a symbol using the stored line range.
127///
128/// Repository builds with semantic support inject a tree-sitter resolver at the
129/// I/O boundary; the no-store core keeps this fallback pure and dependency-free.
130pub fn resolve_current_symbol(
131    _source: &[u8],
132    _file_path: &Path,
133    _symbol: &str,
134    stored: Option<(u32, u32)>,
135) -> SymbolResolution {
136    match stored {
137        Some((start, end)) => SymbolResolution::Resolved { start, end },
138        None => SymbolResolution::Missing,
139    }
140}