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(
10 sha: String,
11 region_anchor: String,
12 field: String,
13 remove: Option<String>,
14 amend: Option<String>,
15) -> Result<()> {
16 if remove.is_none() && amend.is_none() {
17 return Err(crate::error::ChronicleError::Config {
18 message: "At least one of --remove or --amend must be specified.".to_string(),
19 location: snafu::Location::default(),
20 });
21 }
22
23 let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
24 source: e,
25 location: snafu::Location::default(),
26 })?;
27 let git_ops = CliOps::new(repo_dir);
28
29 let full_sha = git_ops
31 .resolve_ref(&sha)
32 .map_err(|e| crate::error::ChronicleError::Git {
33 source: e,
34 location: snafu::Location::default(),
35 })?;
36
37 let note_opt = git_ops
39 .note_read(&full_sha)
40 .map_err(|e| crate::error::ChronicleError::Git {
41 source: e,
42 location: snafu::Location::default(),
43 })?;
44
45 let note = match note_opt {
46 Some(n) => n,
47 None => {
48 return Err(crate::error::ChronicleError::Config {
49 message: format!("No annotation found for commit {sha}. Cannot apply correction."),
50 location: snafu::Location::default(),
51 });
52 }
53 };
54
55 let mut annotation: Annotation =
56 serde_json::from_str(¬e).map_err(|e| crate::error::ChronicleError::Json {
57 source: e,
58 location: snafu::Location::default(),
59 })?;
60
61 let region_idx = annotation
63 .regions
64 .iter()
65 .position(|r| r.ast_anchor.name == region_anchor);
66
67 let region_idx = match region_idx {
68 Some(i) => i,
69 None => {
70 let available: Vec<&str> = annotation
71 .regions
72 .iter()
73 .map(|r| r.ast_anchor.name.as_str())
74 .collect();
75 return Err(crate::error::ChronicleError::Config {
76 message: format!(
77 "No region matching '{}' found in annotation for commit {}. Available regions: {}",
78 region_anchor,
79 sha,
80 available.join(", ")
81 ),
82 location: snafu::Location::default(),
83 });
84 }
85 };
86
87 validate_field(&annotation.regions[region_idx], &field)?;
89
90 let correction_type = if remove.is_some() && amend.is_some() {
92 CorrectionType::Amend
94 } else if remove.is_some() {
95 CorrectionType::Remove
96 } else {
97 CorrectionType::Amend
98 };
99
100 let author = resolve_author(&git_ops);
101 let timestamp = chrono::Utc::now().to_rfc3339();
102
103 let reason = match (&remove, &amend) {
104 (Some(val), Some(replacement)) => {
105 format!("Removed '{}', replaced with '{}'", val, replacement)
106 }
107 (Some(val), None) => format!("Removed '{}'", val),
108 (None, Some(text)) => format!("Amended with '{}'", text),
109 (None, None) => unreachable!(),
110 };
111
112 let correction = Correction {
113 field: field.clone(),
114 correction_type,
115 reason,
116 target_value: remove.clone(),
117 replacement: amend.clone(),
118 timestamp,
119 author,
120 };
121
122 annotation.regions[region_idx].corrections.push(correction);
123
124 let updated_json = serde_json::to_string_pretty(&annotation).map_err(|e| {
125 crate::error::ChronicleError::Json {
126 source: e,
127 location: snafu::Location::default(),
128 }
129 })?;
130
131 git_ops.note_write(&full_sha, &updated_json).map_err(|e| {
132 crate::error::ChronicleError::Git {
133 source: e,
134 location: snafu::Location::default(),
135 }
136 })?;
137
138 let short_sha = &full_sha[..7.min(full_sha.len())];
139 eprintln!("Corrected annotation on commit {short_sha}, region {region_anchor}");
140 eprintln!(" Field: {field}");
141 if let Some(ref val) = remove {
142 eprintln!(" Removed: \"{val}\"");
143 }
144 if let Some(ref val) = amend {
145 eprintln!(" Amended: \"{val}\"");
146 }
147 eprintln!(" Correction stored in refs/notes/chronicle");
148
149 Ok(())
150}
151
152fn validate_field(region: &crate::schema::annotation::RegionAnnotation, field: &str) -> Result<()> {
154 let is_empty = match field {
155 "intent" => region.intent.is_empty(),
156 "reasoning" => region.reasoning.as_ref().is_none_or(|s| s.is_empty()),
157 "constraints" => region.constraints.is_empty(),
158 "risk_notes" => region.risk_notes.as_ref().is_none_or(|s| s.is_empty()),
159 "semantic_dependencies" => region.semantic_dependencies.is_empty(),
160 "tags" => region.tags.is_empty(),
161 other => {
162 return Err(crate::error::ChronicleError::Config {
163 message: format!(
164 "Unknown field '{}'. Valid fields: intent, reasoning, constraints, risk_notes, semantic_dependencies, tags",
165 other
166 ),
167 location: snafu::Location::default(),
168 });
169 }
170 };
171
172 if is_empty {
173 return Err(crate::error::ChronicleError::Config {
174 message: format!(
175 "Field '{}' is empty in region '{}'. Nothing to correct.",
176 field, region.ast_anchor.name
177 ),
178 location: snafu::Location::default(),
179 });
180 }
181
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::schema::annotation::*;
189 use crate::schema::correction::Correction;
190
191 fn make_region() -> RegionAnnotation {
192 RegionAnnotation {
193 file: "src/main.rs".to_string(),
194 ast_anchor: AstAnchor {
195 unit_type: "fn".to_string(),
196 name: "connect".to_string(),
197 signature: None,
198 },
199 lines: LineRange { start: 1, end: 10 },
200 intent: "Connects to broker".to_string(),
201 reasoning: Some("Uses mTLS for security".to_string()),
202 constraints: vec![Constraint {
203 text: "Must drain queue before reconnecting".to_string(),
204 source: ConstraintSource::Author,
205 }],
206 semantic_dependencies: vec![SemanticDependency {
207 file: "src/tls.rs".to_string(),
208 anchor: "TlsConfig".to_string(),
209 nature: "uses".to_string(),
210 }],
211 related_annotations: vec![],
212 tags: vec!["mqtt".to_string(), "networking".to_string()],
213 risk_notes: Some("High risk if TLS config changes".to_string()),
214 corrections: vec![],
215 }
216 }
217
218 #[test]
219 fn test_validate_field_valid() {
220 let region = make_region();
221 assert!(validate_field(®ion, "intent").is_ok());
222 assert!(validate_field(®ion, "reasoning").is_ok());
223 assert!(validate_field(®ion, "constraints").is_ok());
224 assert!(validate_field(®ion, "risk_notes").is_ok());
225 assert!(validate_field(®ion, "semantic_dependencies").is_ok());
226 assert!(validate_field(®ion, "tags").is_ok());
227 }
228
229 #[test]
230 fn test_validate_field_unknown() {
231 let region = make_region();
232 let result = validate_field(®ion, "nonexistent");
233 assert!(result.is_err());
234 let err = result.unwrap_err().to_string();
235 assert!(err.contains("Unknown field"));
236 }
237
238 #[test]
239 fn test_validate_field_empty() {
240 let mut region = make_region();
241 region.constraints = vec![];
242 let result = validate_field(®ion, "constraints");
243 assert!(result.is_err());
244 let err = result.unwrap_err().to_string();
245 assert!(err.contains("empty"));
246 }
247
248 #[test]
249 fn test_validate_field_none_reasoning() {
250 let mut region = make_region();
251 region.reasoning = None;
252 let result = validate_field(®ion, "reasoning");
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn test_correction_accumulates_on_region() {
258 let mut region = make_region();
259
260 let c1 = Correction {
261 field: "constraints".to_string(),
262 correction_type: CorrectionType::Remove,
263 reason: "No longer required".to_string(),
264 target_value: Some("Must drain queue before reconnecting".to_string()),
265 replacement: None,
266 timestamp: "2025-12-20T14:30:00Z".to_string(),
267 author: "tester".to_string(),
268 };
269 region.corrections.push(c1);
270
271 let c2 = Correction {
272 field: "reasoning".to_string(),
273 correction_type: CorrectionType::Amend,
274 reason: "Updated reasoning".to_string(),
275 target_value: None,
276 replacement: Some("Uses mTLS v2".to_string()),
277 timestamp: "2025-12-21T10:00:00Z".to_string(),
278 author: "tester".to_string(),
279 };
280 region.corrections.push(c2);
281
282 assert_eq!(region.corrections.len(), 2);
283 assert_eq!(
284 region.corrections[0].correction_type,
285 CorrectionType::Remove
286 );
287 assert_eq!(region.corrections[1].correction_type, CorrectionType::Amend);
288 }
289
290 #[test]
291 fn test_corrections_survive_json_roundtrip() {
292 let mut region = make_region();
293 region.corrections.push(Correction {
294 field: "constraints".to_string(),
295 correction_type: CorrectionType::Flag,
296 reason: "Seems wrong".to_string(),
297 target_value: None,
298 replacement: None,
299 timestamp: "2025-12-20T14:30:00Z".to_string(),
300 author: "tester".to_string(),
301 });
302
303 let json = serde_json::to_string_pretty(®ion).unwrap();
304 let parsed: RegionAnnotation = serde_json::from_str(&json).unwrap();
305
306 assert_eq!(parsed.corrections.len(), 1);
307 assert_eq!(parsed.corrections[0].field, "constraints");
308 assert_eq!(parsed.corrections[0].correction_type, CorrectionType::Flag);
309 }
310
311 #[test]
312 fn test_annotation_without_corrections_deserializes() {
313 let json = r#"{
315 "file": "src/main.rs",
316 "ast_anchor": {"unit_type": "fn", "name": "main", "signature": null},
317 "lines": {"start": 1, "end": 10},
318 "intent": "entry point",
319 "reasoning": null,
320 "constraints": [],
321 "semantic_dependencies": [],
322 "related_annotations": [],
323 "tags": [],
324 "risk_notes": null
325 }"#;
326
327 let region: RegionAnnotation = serde_json::from_str(json).unwrap();
328 assert!(region.corrections.is_empty());
329 }
330}