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 file passed the rename confidence threshold.
23    AmbiguousFileMove { candidate_paths: Vec<String> },
24    /// No provenance data stored -- staleness cannot be determined.
25    Unknown,
26}
27
28/// Check an annotation's staleness against already-loaded source bytes.
29pub fn annotation_status_for_source(
30    annotation: &Annotation,
31    scope: &AnnotationScope,
32    source: &[u8],
33    file_path: &Path,
34) -> StalenessStatus {
35    annotation_status_for_source_with_symbol_resolver(
36        annotation,
37        scope,
38        source,
39        file_path,
40        resolve_current_symbol,
41    )
42}
43
44/// Check an annotation's staleness with an injected symbol resolver.
45pub fn annotation_status_for_source_with_symbol_resolver(
46    annotation: &Annotation,
47    scope: &AnnotationScope,
48    source: &[u8],
49    file_path: &Path,
50    mut resolve_symbol: impl FnMut(&[u8], &Path, &str, Option<(u32, u32)>) -> Option<(u32, u32)>,
51) -> StalenessStatus {
52    let Some(revision) = annotation.current_revision() else {
53        return StalenessStatus::Unknown;
54    };
55    let expected_hash = match &revision.source_hash {
56        Some(h) => h,
57        None => return StalenessStatus::Unknown,
58    };
59
60    let scoped_bytes = match scope {
61        AnnotationScope::File => source.to_vec(),
62        AnnotationScope::Lines(start, end) => extract_line_range(source, *start, *end),
63        AnnotationScope::Symbol {
64            name,
65            resolved_lines,
66        } => match resolve_symbol(source, file_path, name, *resolved_lines) {
67            Some((start, end)) => extract_line_range(source, start, end),
68            None => {
69                return StalenessStatus::SymbolMissing {
70                    symbol: name.clone(),
71                };
72            }
73        },
74    };
75
76    let current_hash = ContentHash::compute(&scoped_bytes);
77    if current_hash == *expected_hash {
78        StalenessStatus::Fresh
79    } else {
80        StalenessStatus::SourceChanged {
81            old_hash: *expected_hash,
82            new_hash: current_hash,
83        }
84    }
85}
86
87/// Extract bytes for a line range from source content.
88///
89/// Lines are 1-indexed. Returns the bytes spanning `start..=end` lines
90/// (inclusive on both ends), joined with newlines.
91pub fn extract_line_range(source: &[u8], start: u32, end: u32) -> Vec<u8> {
92    let text = std::str::from_utf8(source).unwrap_or("");
93    let lines: Vec<&str> = text.lines().collect();
94    let start_idx = (start as usize).saturating_sub(1);
95    let end_idx = (end as usize).min(lines.len());
96    if start_idx >= end_idx {
97        return Vec::new();
98    }
99    lines[start_idx..end_idx].join("\n").into_bytes()
100}
101
102/// Resolve a symbol using the stored line range.
103///
104/// Repository builds with semantic support inject a tree-sitter resolver at the
105/// I/O boundary; the no-store core keeps this fallback pure and dependency-free.
106pub fn resolve_current_symbol(
107    _source: &[u8],
108    _file_path: &Path,
109    _symbol: &str,
110    stored: Option<(u32, u32)>,
111) -> Option<(u32, u32)> {
112    stored
113}