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
7pub 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 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 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(¬e_content).map_err(|e| {
43 crate::error::ChronicleError::Json {
44 source: e,
45 location: snafu::Location::default(),
46 }
47 })?;
48
49 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 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::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 assert_eq!(
246 find_matching_region(&annotation, "src/lib.rs", None),
247 Some(0)
248 );
249 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}