Skip to main content

chronicle/cli/
flag.rs

1use crate::error::Result;
2use crate::git::{CliOps, GitOps};
3use crate::schema::annotation::Annotation;
4use crate::schema::correction::{resolve_author, Correction, CorrectionType};
5
6/// Run the `git chronicle flag` command.
7///
8/// Flags the most recent annotation for a code region as potentially inaccurate.
9/// 1. Find commits that touched the file via git log --follow.
10/// 2. For each commit (newest first), look for an annotation with a matching region.
11/// 3. Append a Flag correction to that region.
12/// 4. Write the updated annotation back.
13pub fn run(path: String, anchor: Option<String>, reason: String) -> Result<()> {
14    let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
15        source: e,
16        location: snafu::Location::default(),
17    })?;
18    let git_ops = CliOps::new(repo_dir);
19
20    // Find commits that touched this file
21    let shas = git_ops
22        .log_for_file(&path)
23        .map_err(|e| crate::error::ChronicleError::Git {
24            source: e,
25            location: snafu::Location::default(),
26        })?;
27
28    // Search for the first commit with a matching annotation/region
29    for sha in &shas {
30        let note_content =
31            match git_ops
32                .note_read(sha)
33                .map_err(|e| crate::error::ChronicleError::Git {
34                    source: e,
35                    location: snafu::Location::default(),
36                })? {
37                Some(n) => n,
38                None => continue,
39            };
40
41        let mut annotation: Annotation = serde_json::from_str(&note_content).map_err(|e| {
42            crate::error::ChronicleError::Json {
43                source: e,
44                location: snafu::Location::default(),
45            }
46        })?;
47
48        // Find the matching region
49        let region_idx = find_matching_region(&annotation, &path, anchor.as_deref());
50        if region_idx.is_none() {
51            continue;
52        }
53        let region_idx = region_idx.unwrap();
54
55        let author = resolve_author(&git_ops);
56        let timestamp = chrono::Utc::now().to_rfc3339();
57
58        let anchor_display = annotation.regions[region_idx].ast_anchor.name.clone();
59
60        let correction = Correction {
61            field: "region".to_string(),
62            correction_type: CorrectionType::Flag,
63            reason: reason.clone(),
64            target_value: None,
65            replacement: None,
66            timestamp,
67            author,
68        };
69
70        annotation.regions[region_idx].corrections.push(correction);
71
72        let updated_json = serde_json::to_string_pretty(&annotation).map_err(|e| {
73            crate::error::ChronicleError::Json {
74                source: e,
75                location: snafu::Location::default(),
76            }
77        })?;
78
79        git_ops
80            .note_write(sha, &updated_json)
81            .map_err(|e| crate::error::ChronicleError::Git {
82                source: e,
83                location: snafu::Location::default(),
84            })?;
85
86        let short_sha = &sha[..7.min(sha.len())];
87        eprintln!("Flagged annotation on commit {short_sha} for {anchor_display}");
88        eprintln!("  Reason: {reason}");
89        eprintln!("  Correction stored in refs/notes/chronicle");
90        return Ok(());
91    }
92
93    // No matching annotation found
94    let target = match &anchor {
95        Some(a) => format!("{path}:{a}"),
96        None => path.clone(),
97    };
98    Err(crate::error::ChronicleError::Config {
99        message: format!(
100            "No annotation found for '{target}'. No commits with matching annotations were found."
101        ),
102        location: snafu::Location::default(),
103    })
104}
105
106/// Find the index of a region in the annotation matching the file path and optional anchor.
107fn find_matching_region(
108    annotation: &Annotation,
109    path: &str,
110    anchor: Option<&str>,
111) -> Option<usize> {
112    fn norm(s: &str) -> &str {
113        s.strip_prefix("./").unwrap_or(s)
114    }
115
116    for (i, region) in annotation.regions.iter().enumerate() {
117        if norm(&region.file) != norm(path) {
118            continue;
119        }
120        match anchor {
121            Some(anchor_name) => {
122                if region.ast_anchor.name == anchor_name {
123                    return Some(i);
124                }
125            }
126            None => {
127                // No anchor specified: match any region for this file
128                return Some(i);
129            }
130        }
131    }
132    None
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::schema::annotation::*;
139
140    #[test]
141    fn test_find_matching_region_by_anchor() {
142        let annotation = Annotation {
143            schema: "chronicle/v1".to_string(),
144            commit: "abc123".to_string(),
145            timestamp: "2025-01-01T00:00:00Z".to_string(),
146            task: None,
147            summary: "test".to_string(),
148            context_level: ContextLevel::Enhanced,
149            regions: vec![
150                RegionAnnotation {
151                    file: "src/main.rs".to_string(),
152                    ast_anchor: AstAnchor {
153                        unit_type: "fn".to_string(),
154                        name: "main".to_string(),
155                        signature: None,
156                    },
157                    lines: LineRange { start: 1, end: 10 },
158                    intent: "entry point".to_string(),
159                    reasoning: None,
160                    constraints: vec![],
161                    semantic_dependencies: vec![],
162                    related_annotations: vec![],
163                    tags: vec![],
164                    risk_notes: None,
165                    corrections: vec![],
166                },
167                RegionAnnotation {
168                    file: "src/main.rs".to_string(),
169                    ast_anchor: AstAnchor {
170                        unit_type: "fn".to_string(),
171                        name: "helper".to_string(),
172                        signature: None,
173                    },
174                    lines: LineRange { start: 12, end: 20 },
175                    intent: "helper fn".to_string(),
176                    reasoning: None,
177                    constraints: vec![],
178                    semantic_dependencies: vec![],
179                    related_annotations: vec![],
180                    tags: vec![],
181                    risk_notes: None,
182                    corrections: vec![],
183                },
184            ],
185            cross_cutting: vec![],
186            provenance: Provenance {
187                operation: ProvenanceOperation::Initial,
188                derived_from: vec![],
189                original_annotations_preserved: false,
190                synthesis_notes: None,
191            },
192        };
193
194        assert_eq!(
195            find_matching_region(&annotation, "src/main.rs", Some("helper")),
196            Some(1)
197        );
198        assert_eq!(
199            find_matching_region(&annotation, "src/main.rs", Some("main")),
200            Some(0)
201        );
202        assert_eq!(
203            find_matching_region(&annotation, "src/main.rs", Some("nonexistent")),
204            None
205        );
206    }
207
208    #[test]
209    fn test_find_matching_region_no_anchor() {
210        let annotation = Annotation {
211            schema: "chronicle/v1".to_string(),
212            commit: "abc123".to_string(),
213            timestamp: "2025-01-01T00:00:00Z".to_string(),
214            task: None,
215            summary: "test".to_string(),
216            context_level: ContextLevel::Enhanced,
217            regions: vec![RegionAnnotation {
218                file: "src/lib.rs".to_string(),
219                ast_anchor: AstAnchor {
220                    unit_type: "mod".to_string(),
221                    name: "lib".to_string(),
222                    signature: None,
223                },
224                lines: LineRange { start: 1, end: 5 },
225                intent: "module".to_string(),
226                reasoning: None,
227                constraints: vec![],
228                semantic_dependencies: vec![],
229                related_annotations: vec![],
230                tags: vec![],
231                risk_notes: None,
232                corrections: vec![],
233            }],
234            cross_cutting: vec![],
235            provenance: Provenance {
236                operation: ProvenanceOperation::Initial,
237                derived_from: vec![],
238                original_annotations_preserved: false,
239                synthesis_notes: None,
240            },
241        };
242
243        // No anchor, matches first region for the file
244        assert_eq!(
245            find_matching_region(&annotation, "src/lib.rs", None),
246            Some(0)
247        );
248        // Wrong file
249        assert_eq!(find_matching_region(&annotation, "src/main.rs", None), None);
250    }
251
252    #[test]
253    fn test_find_matching_region_dot_slash_normalization() {
254        let annotation = Annotation {
255            schema: "chronicle/v1".to_string(),
256            commit: "abc123".to_string(),
257            timestamp: "2025-01-01T00:00:00Z".to_string(),
258            task: None,
259            summary: "test".to_string(),
260            context_level: ContextLevel::Enhanced,
261            regions: vec![RegionAnnotation {
262                file: "./src/main.rs".to_string(),
263                ast_anchor: AstAnchor {
264                    unit_type: "fn".to_string(),
265                    name: "main".to_string(),
266                    signature: None,
267                },
268                lines: LineRange { start: 1, end: 10 },
269                intent: "entry".to_string(),
270                reasoning: None,
271                constraints: vec![],
272                semantic_dependencies: vec![],
273                related_annotations: vec![],
274                tags: vec![],
275                risk_notes: None,
276                corrections: vec![],
277            }],
278            cross_cutting: vec![],
279            provenance: Provenance {
280                operation: ProvenanceOperation::Initial,
281                derived_from: vec![],
282                original_annotations_preserved: false,
283                synthesis_notes: None,
284            },
285        };
286
287        assert_eq!(
288            find_matching_region(&annotation, "src/main.rs", Some("main")),
289            Some(0)
290        );
291    }
292}