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