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