Skip to main content

chronicle/show/
data.rs

1use crate::ast::outline::OutlineEntry;
2use crate::error::{ChronicleError, Result};
3use crate::git::GitOps;
4use crate::read::{self, MatchedRegion, ReadQuery};
5use crate::schema::annotation::{ContextLevel, Provenance, RegionAnnotation};
6
7/// A region annotation with its commit-level metadata.
8#[derive(Debug, Clone)]
9pub struct RegionRef {
10    pub region: RegionAnnotation,
11    pub commit: String,
12    pub timestamp: String,
13    pub summary: String,
14    pub context_level: ContextLevel,
15    pub provenance: Provenance,
16}
17
18/// Maps each source line to the annotation regions covering it.
19#[derive(Debug)]
20pub struct LineAnnotationMap {
21    /// For each line (index 0 = line 1), indices into ShowData.regions.
22    coverage: Vec<Vec<usize>>,
23}
24
25impl LineAnnotationMap {
26    /// Build the map from regions and the total number of source lines.
27    pub fn build_from_regions(regions: &[RegionRef], total_lines: usize) -> Self {
28        Self::build(regions, total_lines)
29    }
30
31    fn build(regions: &[RegionRef], total_lines: usize) -> Self {
32        let mut coverage = vec![Vec::new(); total_lines];
33        for (idx, r) in regions.iter().enumerate() {
34            let start = r.region.lines.start.saturating_sub(1) as usize;
35            let end = (r.region.lines.end as usize).min(total_lines);
36            for slot in &mut coverage[start..end] {
37                slot.push(idx);
38            }
39        }
40        Self { coverage }
41    }
42
43    /// Get region indices covering a given line (1-indexed).
44    pub fn regions_at_line(&self, line: u32) -> &[usize] {
45        let idx = line.saturating_sub(1) as usize;
46        self.coverage.get(idx).map(|v| v.as_slice()).unwrap_or(&[])
47    }
48
49    /// Find the next line >= `from` (1-indexed) that has annotation coverage.
50    /// Returns None if no annotated lines from that point.
51    pub fn next_annotated_line(&self, from: u32) -> Option<u32> {
52        let start = from.saturating_sub(1) as usize;
53        for (i, regions) in self.coverage[start..].iter().enumerate() {
54            if !regions.is_empty() {
55                return Some((start + i) as u32 + 1);
56            }
57        }
58        None
59    }
60
61    /// Find the previous line <= `from` (1-indexed) that has annotation coverage.
62    pub fn prev_annotated_line(&self, from: u32) -> Option<u32> {
63        let end = (from as usize).min(self.coverage.len());
64        for i in (0..end).rev() {
65            if !self.coverage[i].is_empty() {
66                return Some(i as u32 + 1);
67            }
68        }
69        None
70    }
71}
72
73/// All data needed to render the show view.
74#[derive(Debug)]
75pub struct ShowData {
76    pub file_path: String,
77    pub commit: String,
78    pub source_lines: Vec<String>,
79    pub outline: Vec<OutlineEntry>,
80    pub regions: Vec<RegionRef>,
81    pub annotation_map: LineAnnotationMap,
82}
83
84/// Build ShowData for a file: read content, parse AST, fetch annotations, map lines.
85pub fn build_show_data(
86    git_ops: &dyn GitOps,
87    file_path: &str,
88    commit: &str,
89    anchor: Option<&str>,
90) -> Result<ShowData> {
91    // Read file content at the given commit
92    let source = git_ops
93        .file_at_commit(std::path::Path::new(file_path), commit)
94        .map_err(|e| ChronicleError::Git {
95            source: e,
96            location: snafu::Location::default(),
97        })?;
98
99    let source_lines: Vec<String> = source.lines().map(String::from).collect();
100    let total_lines = source_lines.len();
101
102    // Parse AST outline (best-effort, non-fatal)
103    let lang = crate::ast::Language::from_path(file_path);
104    let outline = crate::ast::extract_outline(&source, lang).unwrap_or_default();
105
106    // Fetch annotations via the read pipeline
107    let query = ReadQuery {
108        file: file_path.to_string(),
109        anchor: anchor.map(String::from),
110        lines: None,
111    };
112    let read_result = read::execute(git_ops, &query)?;
113
114    // Convert MatchedRegions to RegionRefs, deduplicating by anchor name
115    // (keep the most recent annotation per region)
116    let regions = dedup_regions(read_result.regions);
117
118    let annotation_map = LineAnnotationMap::build(&regions, total_lines);
119
120    Ok(ShowData {
121        file_path: file_path.to_string(),
122        commit: commit.to_string(),
123        source_lines,
124        outline,
125        regions,
126        annotation_map,
127    })
128}
129
130/// Deduplicate matched regions: for each file+anchor, keep the most recent.
131fn dedup_regions(matched: Vec<MatchedRegion>) -> Vec<RegionRef> {
132    use std::collections::HashMap;
133
134    let mut best: HashMap<String, RegionRef> = HashMap::new();
135
136    for m in matched {
137        let key = format!("{}:{}", m.region.file, m.region.ast_anchor.name);
138        let region_ref = RegionRef {
139            region: m.region,
140            commit: m.commit,
141            timestamp: m.timestamp,
142            summary: m.summary,
143            context_level: ContextLevel::Inferred, // read pipeline doesn't return this yet
144            provenance: Provenance {
145                operation: crate::schema::annotation::ProvenanceOperation::Initial,
146                derived_from: vec![],
147                original_annotations_preserved: false,
148                synthesis_notes: None,
149            },
150        };
151        let existing = best.get(&key);
152        if existing.is_none() || region_ref.timestamp > existing.unwrap().timestamp {
153            best.insert(key, region_ref);
154        }
155    }
156
157    let mut regions: Vec<RegionRef> = best.into_values().collect();
158    regions.sort_by_key(|r| r.region.lines.start);
159    regions
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_line_annotation_map_empty() {
168        let map = LineAnnotationMap::build(&[], 10);
169        assert!(map.regions_at_line(1).is_empty());
170        assert!(map.regions_at_line(5).is_empty());
171        assert!(map.next_annotated_line(1).is_none());
172        assert!(map.prev_annotated_line(10).is_none());
173    }
174
175    #[test]
176    fn test_line_annotation_map_coverage() {
177        use crate::schema::annotation::*;
178
179        let regions = vec![RegionRef {
180            region: RegionAnnotation {
181                file: "test.rs".to_string(),
182                ast_anchor: AstAnchor {
183                    unit_type: "function".to_string(),
184                    name: "foo".to_string(),
185                    signature: None,
186                },
187                lines: LineRange { start: 3, end: 5 },
188                intent: "test".to_string(),
189                reasoning: None,
190                constraints: vec![],
191                semantic_dependencies: vec![],
192                related_annotations: vec![],
193                tags: vec![],
194                risk_notes: None,
195                corrections: vec![],
196            },
197            commit: "abc".to_string(),
198            timestamp: "2025-01-01T00:00:00Z".to_string(),
199            summary: "test".to_string(),
200            context_level: ContextLevel::Inferred,
201            provenance: Provenance {
202                operation: ProvenanceOperation::Initial,
203                derived_from: vec![],
204                original_annotations_preserved: false,
205                synthesis_notes: None,
206            },
207        }];
208
209        let map = LineAnnotationMap::build(&regions, 10);
210        assert!(map.regions_at_line(1).is_empty());
211        assert!(map.regions_at_line(2).is_empty());
212        assert_eq!(map.regions_at_line(3), &[0]);
213        assert_eq!(map.regions_at_line(4), &[0]);
214        assert_eq!(map.regions_at_line(5), &[0]);
215        assert!(map.regions_at_line(6).is_empty());
216    }
217
218    #[test]
219    fn test_next_prev_annotated_line() {
220        use crate::schema::annotation::*;
221
222        let regions = vec![RegionRef {
223            region: RegionAnnotation {
224                file: "test.rs".to_string(),
225                ast_anchor: AstAnchor {
226                    unit_type: "function".to_string(),
227                    name: "foo".to_string(),
228                    signature: None,
229                },
230                lines: LineRange { start: 5, end: 8 },
231                intent: "test".to_string(),
232                reasoning: None,
233                constraints: vec![],
234                semantic_dependencies: vec![],
235                related_annotations: vec![],
236                tags: vec![],
237                risk_notes: None,
238                corrections: vec![],
239            },
240            commit: "abc".to_string(),
241            timestamp: "2025-01-01T00:00:00Z".to_string(),
242            summary: "test".to_string(),
243            context_level: ContextLevel::Inferred,
244            provenance: Provenance {
245                operation: ProvenanceOperation::Initial,
246                derived_from: vec![],
247                original_annotations_preserved: false,
248                synthesis_notes: None,
249            },
250        }];
251
252        let map = LineAnnotationMap::build(&regions, 15);
253        assert_eq!(map.next_annotated_line(1), Some(5));
254        assert_eq!(map.next_annotated_line(5), Some(5));
255        assert_eq!(map.next_annotated_line(9), None);
256        assert_eq!(map.prev_annotated_line(10), Some(8));
257        assert_eq!(map.prev_annotated_line(4), None);
258    }
259}