Skip to main content

chronicle/cli/
flag.rs

1use crate::error::Result;
2use crate::git::{CliOps, GitOps};
3use crate::schema::v1;
4type Annotation = v1::Annotation;
5use crate::schema::correction::{resolve_author, Correction, CorrectionType};
6
7/// Run the `git chronicle flag` command.
8///
9/// Flags the most recent annotation for a code region as potentially inaccurate.
10/// 1. Find commits that touched the file via git log --follow.
11/// 2. For each commit (newest first), look for an annotation with a matching region.
12/// 3. Append a Flag correction to that region.
13/// 4. Write the updated annotation back.
14pub fn run(path: String, anchor: Option<String>, reason: String) -> Result<()> {
15    let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
16        source: e,
17        location: snafu::Location::default(),
18    })?;
19    let git_ops = CliOps::new(repo_dir);
20
21    // Find commits that touched this file
22    let shas = git_ops
23        .log_for_file(&path)
24        .map_err(|e| crate::error::ChronicleError::Git {
25            source: e,
26            location: snafu::Location::default(),
27        })?;
28
29    // Search for the first commit with a matching annotation/region
30    for sha in &shas {
31        let note_content =
32            match git_ops
33                .note_read(sha)
34                .map_err(|e| crate::error::ChronicleError::Git {
35                    source: e,
36                    location: snafu::Location::default(),
37                })? {
38                Some(n) => n,
39                None => continue,
40            };
41
42        let mut annotation: Annotation = serde_json::from_str(&note_content).map_err(|e| {
43            crate::error::ChronicleError::Json {
44                source: e,
45                location: snafu::Location::default(),
46            }
47        })?;
48
49        // Find the matching region
50        let region_idx = match find_matching_region(&annotation, &path, anchor.as_deref()) {
51            Some(idx) => idx,
52            None => continue,
53        };
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::common::{AstAnchor, LineRange};
139    use crate::schema::v1::{ContextLevel, Provenance, ProvenanceOperation, RegionAnnotation};
140
141    #[test]
142    fn test_find_matching_region_by_anchor() {
143        let annotation = Annotation {
144            schema: "chronicle/v1".to_string(),
145            commit: "abc123".to_string(),
146            timestamp: "2025-01-01T00:00:00Z".to_string(),
147            task: None,
148            summary: "test".to_string(),
149            context_level: ContextLevel::Enhanced,
150            regions: vec![
151                RegionAnnotation {
152                    file: "src/main.rs".to_string(),
153                    ast_anchor: AstAnchor {
154                        unit_type: "fn".to_string(),
155                        name: "main".to_string(),
156                        signature: None,
157                    },
158                    lines: LineRange { start: 1, end: 10 },
159                    intent: "entry point".to_string(),
160                    reasoning: None,
161                    constraints: vec![],
162                    semantic_dependencies: vec![],
163                    related_annotations: vec![],
164                    tags: vec![],
165                    risk_notes: None,
166                    corrections: vec![],
167                },
168                RegionAnnotation {
169                    file: "src/main.rs".to_string(),
170                    ast_anchor: AstAnchor {
171                        unit_type: "fn".to_string(),
172                        name: "helper".to_string(),
173                        signature: None,
174                    },
175                    lines: LineRange { start: 12, end: 20 },
176                    intent: "helper fn".to_string(),
177                    reasoning: None,
178                    constraints: vec![],
179                    semantic_dependencies: vec![],
180                    related_annotations: vec![],
181                    tags: vec![],
182                    risk_notes: None,
183                    corrections: vec![],
184                },
185            ],
186            cross_cutting: vec![],
187            provenance: Provenance {
188                operation: ProvenanceOperation::Initial,
189                derived_from: vec![],
190                original_annotations_preserved: false,
191                synthesis_notes: None,
192            },
193        };
194
195        assert_eq!(
196            find_matching_region(&annotation, "src/main.rs", Some("helper")),
197            Some(1)
198        );
199        assert_eq!(
200            find_matching_region(&annotation, "src/main.rs", Some("main")),
201            Some(0)
202        );
203        assert_eq!(
204            find_matching_region(&annotation, "src/main.rs", Some("nonexistent")),
205            None
206        );
207    }
208
209    #[test]
210    fn test_find_matching_region_no_anchor() {
211        let annotation = Annotation {
212            schema: "chronicle/v1".to_string(),
213            commit: "abc123".to_string(),
214            timestamp: "2025-01-01T00:00:00Z".to_string(),
215            task: None,
216            summary: "test".to_string(),
217            context_level: ContextLevel::Enhanced,
218            regions: vec![RegionAnnotation {
219                file: "src/lib.rs".to_string(),
220                ast_anchor: AstAnchor {
221                    unit_type: "mod".to_string(),
222                    name: "lib".to_string(),
223                    signature: None,
224                },
225                lines: LineRange { start: 1, end: 5 },
226                intent: "module".to_string(),
227                reasoning: None,
228                constraints: vec![],
229                semantic_dependencies: vec![],
230                related_annotations: vec![],
231                tags: vec![],
232                risk_notes: None,
233                corrections: vec![],
234            }],
235            cross_cutting: vec![],
236            provenance: Provenance {
237                operation: ProvenanceOperation::Initial,
238                derived_from: vec![],
239                original_annotations_preserved: false,
240                synthesis_notes: None,
241            },
242        };
243
244        // No anchor, matches first region for the file
245        assert_eq!(
246            find_matching_region(&annotation, "src/lib.rs", None),
247            Some(0)
248        );
249        // Wrong file
250        assert_eq!(find_matching_region(&annotation, "src/main.rs", None), None);
251    }
252
253    #[test]
254    fn test_find_matching_region_dot_slash_normalization() {
255        let annotation = Annotation {
256            schema: "chronicle/v1".to_string(),
257            commit: "abc123".to_string(),
258            timestamp: "2025-01-01T00:00:00Z".to_string(),
259            task: None,
260            summary: "test".to_string(),
261            context_level: ContextLevel::Enhanced,
262            regions: vec![RegionAnnotation {
263                file: "./src/main.rs".to_string(),
264                ast_anchor: AstAnchor {
265                    unit_type: "fn".to_string(),
266                    name: "main".to_string(),
267                    signature: None,
268                },
269                lines: LineRange { start: 1, end: 10 },
270                intent: "entry".to_string(),
271                reasoning: None,
272                constraints: vec![],
273                semantic_dependencies: vec![],
274                related_annotations: vec![],
275                tags: vec![],
276                risk_notes: None,
277                corrections: vec![],
278            }],
279            cross_cutting: vec![],
280            provenance: Provenance {
281                operation: ProvenanceOperation::Initial,
282                derived_from: vec![],
283                original_annotations_preserved: false,
284                synthesis_notes: None,
285            },
286        };
287
288        assert_eq!(
289            find_matching_region(&annotation, "src/main.rs", Some("main")),
290            Some(0)
291        );
292    }
293}