1use crate::error::Result;
2use crate::git::{CliOps, GitOps};
3use crate::schema::annotation::Annotation;
4use crate::schema::correction::{resolve_author, Correction, CorrectionType};
5
6pub 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 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 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(¬e_content).map_err(|e| {
42 crate::error::ChronicleError::Json {
43 source: e,
44 location: snafu::Location::default(),
45 }
46 })?;
47
48 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 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
106fn 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(®ion.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 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 assert_eq!(
245 find_matching_region(&annotation, "src/lib.rs", None),
246 Some(0)
247 );
248 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}