Skip to main content

chronicle/cli/
correct.rs

1use crate::error::Result;
2use crate::git::{CliOps, GitOps};
3use crate::schema::annotation::Annotation;
4use crate::schema::correction::{resolve_author, Correction, CorrectionType};
5
6/// Run the `git chronicle correct` command.
7///
8/// Applies a precise correction to a specific field in a region annotation.
9pub 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    // Resolve short SHA to full if needed
30    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    // Read the existing annotation
38    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(&note).map_err(|e| crate::error::ChronicleError::Json {
57            source: e,
58            location: snafu::Location::default(),
59        })?;
60
61    // Find the matching region by anchor name
62    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 that the field exists and is non-empty
88    validate_field(&annotation.regions[region_idx], &field)?;
89
90    // Determine correction type
91    let correction_type = if remove.is_some() && amend.is_some() {
92        // Both --remove and --amend: remove the old, provide replacement
93        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
152/// Validate that the given field name corresponds to a non-empty field on the region.
153fn 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(&region, "intent").is_ok());
222        assert!(validate_field(&region, "reasoning").is_ok());
223        assert!(validate_field(&region, "constraints").is_ok());
224        assert!(validate_field(&region, "risk_notes").is_ok());
225        assert!(validate_field(&region, "semantic_dependencies").is_ok());
226        assert!(validate_field(&region, "tags").is_ok());
227    }
228
229    #[test]
230    fn test_validate_field_unknown() {
231        let region = make_region();
232        let result = validate_field(&region, "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(&region, "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(&region, "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(&region).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        // Simulate an annotation from before corrections were added (no corrections field)
314        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}