Skip to main content

chronicle/cli/
correct.rs

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