Skip to main content

chronicle/show/
data.rs

1use crate::error::{ChronicleError, Result};
2use crate::git::GitOps;
3use crate::read::{self, MatchedAnnotation, ReadQuery};
4use crate::schema::common::{AstAnchor, LineRange};
5use crate::schema::v1::{
6    Constraint, ConstraintSource, ContextLevel, Provenance, ProvenanceOperation, RegionAnnotation,
7};
8use crate::schema::v3;
9
10/// A region annotation with its commit-level metadata.
11#[derive(Debug, Clone)]
12pub struct RegionRef {
13    pub region: RegionAnnotation,
14    pub commit: String,
15    pub timestamp: String,
16    pub summary: String,
17    pub context_level: ContextLevel,
18    pub provenance: Provenance,
19}
20
21/// Maps each source line to the annotation regions covering it.
22#[derive(Debug)]
23pub struct LineAnnotationMap {
24    /// For each line (index 0 = line 1), indices into ShowData.regions.
25    coverage: Vec<Vec<usize>>,
26}
27
28impl LineAnnotationMap {
29    /// Build the map from regions and the total number of source lines.
30    pub fn build_from_regions(regions: &[RegionRef], total_lines: usize) -> Self {
31        Self::build(regions, total_lines)
32    }
33
34    fn build(regions: &[RegionRef], total_lines: usize) -> Self {
35        let mut coverage = vec![Vec::new(); total_lines];
36        for (idx, r) in regions.iter().enumerate() {
37            let start = r.region.lines.start.saturating_sub(1) as usize;
38            let end = (r.region.lines.end as usize).min(total_lines);
39            for slot in &mut coverage[start..end] {
40                slot.push(idx);
41            }
42        }
43        Self { coverage }
44    }
45
46    /// Get region indices covering a given line (1-indexed).
47    pub fn regions_at_line(&self, line: u32) -> &[usize] {
48        let idx = line.saturating_sub(1) as usize;
49        self.coverage.get(idx).map(|v| v.as_slice()).unwrap_or(&[])
50    }
51
52    /// Find the next line >= `from` (1-indexed) that has annotation coverage.
53    /// Returns None if no annotated lines from that point.
54    pub fn next_annotated_line(&self, from: u32) -> Option<u32> {
55        let start = from.saturating_sub(1) as usize;
56        for (i, regions) in self.coverage[start..].iter().enumerate() {
57            if !regions.is_empty() {
58                return Some((start + i) as u32 + 1);
59            }
60        }
61        None
62    }
63
64    /// Find the previous line <= `from` (1-indexed) that has annotation coverage.
65    pub fn prev_annotated_line(&self, from: u32) -> Option<u32> {
66        let end = (from as usize).min(self.coverage.len());
67        for i in (0..end).rev() {
68            if !self.coverage[i].is_empty() {
69                return Some(i as u32 + 1);
70            }
71        }
72        None
73    }
74}
75
76/// All data needed to render the show view.
77#[derive(Debug)]
78pub struct ShowData {
79    pub file_path: String,
80    pub commit: String,
81    pub source_lines: Vec<String>,
82    pub regions: Vec<RegionRef>,
83    pub annotation_map: LineAnnotationMap,
84}
85
86/// Build ShowData for a file: read content, parse AST, fetch annotations, map lines.
87pub fn build_show_data(
88    git_ops: &dyn GitOps,
89    file_path: &str,
90    commit: &str,
91    anchor: Option<&str>,
92) -> Result<ShowData> {
93    // Read file content at the given commit
94    let source = git_ops
95        .file_at_commit(std::path::Path::new(file_path), commit)
96        .map_err(|e| ChronicleError::Git {
97            source: e,
98            location: snafu::Location::default(),
99        })?;
100
101    let source_lines: Vec<String> = source.lines().map(String::from).collect();
102    let total_lines = source_lines.len();
103
104    // Fetch annotations via the read pipeline
105    let query = ReadQuery {
106        file: file_path.to_string(),
107        anchor: anchor.map(String::from),
108        lines: None,
109    };
110    let read_result = read::execute(git_ops, &query)?;
111
112    // Convert v3 MatchedAnnotations to v1-style RegionRefs for the show TUI
113    let regions = convert_to_region_refs(read_result.annotations, file_path);
114
115    let annotation_map = LineAnnotationMap::build(&regions, total_lines);
116
117    Ok(ShowData {
118        file_path: file_path.to_string(),
119        commit: commit.to_string(),
120        source_lines,
121        regions,
122        annotation_map,
123    })
124}
125
126/// Convert v3 MatchedAnnotations into v1-style RegionRefs for the show TUI.
127///
128/// Each v3 wisdom entry with a file reference becomes a RegionRef.
129/// Annotations without file-scoped wisdom get a synthetic commit-level region.
130fn convert_to_region_refs(annotations: Vec<MatchedAnnotation>, file_path: &str) -> Vec<RegionRef> {
131    use std::collections::HashMap;
132
133    let mut best: HashMap<String, RegionRef> = HashMap::new();
134
135    for ann in annotations {
136        if ann.wisdom.is_empty() {
137            // No wisdom entries — create a synthetic commit-level region.
138            let key = format!("{}:{}", file_path, "__commit_level__");
139            let region_ref = RegionRef {
140                region: RegionAnnotation {
141                    file: file_path.to_string(),
142                    ast_anchor: AstAnchor {
143                        unit_type: "commit".to_string(),
144                        name: "(commit-level)".to_string(),
145                        signature: None,
146                    },
147                    lines: LineRange { start: 1, end: 1 },
148                    intent: ann.summary.clone(),
149                    reasoning: None,
150                    constraints: vec![],
151                    semantic_dependencies: vec![],
152                    related_annotations: vec![],
153                    tags: vec![],
154                    risk_notes: None,
155                    corrections: vec![],
156                },
157                commit: ann.commit.clone(),
158                timestamp: ann.timestamp.clone(),
159                summary: ann.summary.clone(),
160                context_level: ContextLevel::Inferred,
161                provenance: Provenance {
162                    operation: ProvenanceOperation::Initial,
163                    derived_from: vec![],
164                    original_annotations_preserved: false,
165                    synthesis_notes: None,
166                },
167            };
168            let existing = best.get(&key);
169            if existing.is_none() || region_ref.timestamp > existing.unwrap().timestamp {
170                best.insert(key, region_ref);
171            }
172            continue;
173        }
174
175        // Group wisdom entries by file
176        let mut wisdom_by_file: HashMap<String, Vec<&v3::WisdomEntry>> = HashMap::new();
177        for w in &ann.wisdom {
178            let f = w.file.clone().unwrap_or_else(|| file_path.to_string());
179            wisdom_by_file.entry(f).or_default().push(w);
180        }
181
182        for (wf, entries) in wisdom_by_file {
183            let key = format!("{}:{}", file_path, wf);
184
185            // Determine line range from wisdom entries
186            let mut line_start = u32::MAX;
187            let mut line_end = 0u32;
188            for w in &entries {
189                if let Some(ref lines) = w.lines {
190                    line_start = line_start.min(lines.start);
191                    line_end = line_end.max(lines.end);
192                }
193            }
194            if line_start == u32::MAX {
195                line_start = 1;
196                line_end = 1;
197            }
198
199            // Map wisdom categories to v1-style fields
200            let mut constraints = Vec::new();
201            let mut risk_notes = Vec::new();
202
203            for w in &entries {
204                match w.category {
205                    v3::WisdomCategory::Gotcha => {
206                        constraints.push(Constraint {
207                            text: w.content.clone(),
208                            source: ConstraintSource::Inferred,
209                        });
210                    }
211                    _ => {
212                        risk_notes.push(w.content.clone());
213                    }
214                }
215            }
216
217            let region_ref = RegionRef {
218                region: RegionAnnotation {
219                    file: file_path.to_string(),
220                    ast_anchor: AstAnchor {
221                        unit_type: "file".to_string(),
222                        name: wf.clone(),
223                        signature: None,
224                    },
225                    lines: LineRange {
226                        start: line_start,
227                        end: line_end,
228                    },
229                    intent: ann.summary.clone(),
230                    reasoning: None,
231                    constraints,
232                    semantic_dependencies: vec![],
233                    related_annotations: vec![],
234                    tags: vec![],
235                    risk_notes: if risk_notes.is_empty() {
236                        None
237                    } else {
238                        Some(risk_notes.join("; "))
239                    },
240                    corrections: vec![],
241                },
242                commit: ann.commit.clone(),
243                timestamp: ann.timestamp.clone(),
244                summary: ann.summary.clone(),
245                context_level: ContextLevel::Inferred,
246                provenance: Provenance {
247                    operation: ProvenanceOperation::Initial,
248                    derived_from: vec![],
249                    original_annotations_preserved: false,
250                    synthesis_notes: None,
251                },
252            };
253
254            let existing = best.get(&key);
255            if existing.is_none() || region_ref.timestamp > existing.unwrap().timestamp {
256                best.insert(key, region_ref);
257            }
258        }
259    }
260
261    let mut regions: Vec<RegionRef> = best.into_values().collect();
262    regions.sort_by_key(|r| r.region.lines.start);
263    regions
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_line_annotation_map_empty() {
272        let map = LineAnnotationMap::build(&[], 10);
273        assert!(map.regions_at_line(1).is_empty());
274        assert!(map.regions_at_line(5).is_empty());
275        assert!(map.next_annotated_line(1).is_none());
276        assert!(map.prev_annotated_line(10).is_none());
277    }
278
279    #[test]
280    fn test_line_annotation_map_coverage() {
281        use crate::schema::common::*;
282        use crate::schema::v1::*;
283
284        let regions = vec![RegionRef {
285            region: RegionAnnotation {
286                file: "test.rs".to_string(),
287                ast_anchor: AstAnchor {
288                    unit_type: "function".to_string(),
289                    name: "foo".to_string(),
290                    signature: None,
291                },
292                lines: LineRange { start: 3, end: 5 },
293                intent: "test".to_string(),
294                reasoning: None,
295                constraints: vec![],
296                semantic_dependencies: vec![],
297                related_annotations: vec![],
298                tags: vec![],
299                risk_notes: None,
300                corrections: vec![],
301            },
302            commit: "abc".to_string(),
303            timestamp: "2025-01-01T00:00:00Z".to_string(),
304            summary: "test".to_string(),
305            context_level: ContextLevel::Inferred,
306            provenance: Provenance {
307                operation: ProvenanceOperation::Initial,
308                derived_from: vec![],
309                original_annotations_preserved: false,
310                synthesis_notes: None,
311            },
312        }];
313
314        let map = LineAnnotationMap::build(&regions, 10);
315        assert!(map.regions_at_line(1).is_empty());
316        assert!(map.regions_at_line(2).is_empty());
317        assert_eq!(map.regions_at_line(3), &[0]);
318        assert_eq!(map.regions_at_line(4), &[0]);
319        assert_eq!(map.regions_at_line(5), &[0]);
320        assert!(map.regions_at_line(6).is_empty());
321    }
322
323    #[test]
324    fn test_next_prev_annotated_line() {
325        use crate::schema::common::*;
326        use crate::schema::v1::*;
327
328        let regions = vec![RegionRef {
329            region: RegionAnnotation {
330                file: "test.rs".to_string(),
331                ast_anchor: AstAnchor {
332                    unit_type: "function".to_string(),
333                    name: "foo".to_string(),
334                    signature: None,
335                },
336                lines: LineRange { start: 5, end: 8 },
337                intent: "test".to_string(),
338                reasoning: None,
339                constraints: vec![],
340                semantic_dependencies: vec![],
341                related_annotations: vec![],
342                tags: vec![],
343                risk_notes: None,
344                corrections: vec![],
345            },
346            commit: "abc".to_string(),
347            timestamp: "2025-01-01T00:00:00Z".to_string(),
348            summary: "test".to_string(),
349            context_level: ContextLevel::Inferred,
350            provenance: Provenance {
351                operation: ProvenanceOperation::Initial,
352                derived_from: vec![],
353                original_annotations_preserved: false,
354                synthesis_notes: None,
355            },
356        }];
357
358        let map = LineAnnotationMap::build(&regions, 15);
359        assert_eq!(map.next_annotated_line(1), Some(5));
360        assert_eq!(map.next_annotated_line(5), Some(5));
361        assert_eq!(map.next_annotated_line(9), None);
362        assert_eq!(map.prev_annotated_line(10), Some(8));
363        assert_eq!(map.prev_annotated_line(4), None);
364    }
365}