Skip to main content

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