Skip to main content

chronicle/cli/
annotate.rs

1use crate::annotate::squash::{
2    collect_source_annotations, collect_source_messages, migrate_amend_annotation,
3    synthesize_squash_annotation, AmendMigrationContext, SquashSynthesisContext,
4};
5use crate::error::chronicle_error::{GitSnafu, JsonSnafu};
6use crate::error::Result;
7use crate::git::{CliOps, GitOps};
8use snafu::ResultExt;
9
10pub struct AnnotateArgs {
11    pub commit: String,
12    pub live: bool,
13    pub squash_sources: Option<String>,
14    pub amend_source: Option<String>,
15    pub summary: Option<String>,
16    pub json_input: Option<String>,
17    pub auto: bool,
18}
19
20pub fn run(args: AnnotateArgs) -> Result<()> {
21    let AnnotateArgs {
22        commit,
23        live,
24        squash_sources,
25        amend_source,
26        summary,
27        json_input,
28        auto,
29    } = args;
30    let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
31        source: e,
32        location: snafu::Location::default(),
33    })?;
34    let git_ops = CliOps::new(repo_dir.clone());
35
36    // Read staged notes (best-effort, don't fail annotation if staging is broken)
37    let git_dir = repo_dir.join(".git");
38    let staged_notes_text = crate::annotate::staging::read_staged(&git_dir)
39        .ok()
40        .filter(|notes| !notes.is_empty())
41        .map(|notes| crate::annotate::staging::format_for_provenance(&notes));
42
43    // --summary: quick annotation with just a summary string
44    if let Some(summary_text) = summary {
45        let input = crate::annotate::live::LiveInput {
46            commit,
47            summary: summary_text,
48            motivation: None,
49            rejected_alternatives: vec![],
50            follow_up: None,
51            decisions: vec![],
52            markers: vec![],
53            effort: None,
54            staged_notes: staged_notes_text.clone(),
55        };
56        let result = crate::annotate::live::handle_annotate_v2(&git_ops, input)?;
57        let _ = crate::annotate::staging::clear_staged(&git_dir);
58        let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
59        println!("{json}");
60        return Ok(());
61    }
62
63    // --json: full annotation JSON on command line
64    if let Some(json_str) = json_input {
65        let mut input: crate::annotate::live::LiveInput =
66            serde_json::from_str(&json_str).context(JsonSnafu)?;
67        input.staged_notes = staged_notes_text.clone();
68        let result = crate::annotate::live::handle_annotate_v2(&git_ops, input)?;
69        let _ = crate::annotate::staging::clear_staged(&git_dir);
70        let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
71        println!("{json}");
72        return Ok(());
73    }
74
75    // --auto: use commit message as summary
76    if auto {
77        let full_sha = git_ops.resolve_ref(&commit).context(GitSnafu)?;
78        let commit_info = git_ops.commit_info(&full_sha).context(GitSnafu)?;
79        let input = crate::annotate::live::LiveInput {
80            commit,
81            summary: commit_info.message,
82            motivation: None,
83            rejected_alternatives: vec![],
84            follow_up: None,
85            decisions: vec![],
86            markers: vec![],
87            effort: None,
88            staged_notes: staged_notes_text.clone(),
89        };
90        let result = crate::annotate::live::handle_annotate_v2(&git_ops, input)?;
91        let _ = crate::annotate::staging::clear_staged(&git_dir);
92        let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
93        println!("{json}");
94        return Ok(());
95    }
96
97    if live {
98        return run_live(&git_ops);
99    }
100
101    // Handle --squash-sources
102    if let Some(sources) = squash_sources {
103        return run_squash_synthesis(&git_ops, &commit, &sources);
104    }
105
106    // Handle --amend-source
107    if let Some(old_sha) = amend_source {
108        return run_amend_migration(&git_ops, &commit, &old_sha);
109    }
110
111    let provider = crate::provider::discover_provider().map_err(|e| {
112        crate::error::ChronicleError::Provider {
113            source: e,
114            location: snafu::Location::default(),
115        }
116    })?;
117
118    let annotation = crate::annotate::run(&git_ops, provider.as_ref(), &commit)?;
119
120    let json = serde_json::to_string_pretty(&annotation).map_err(|e| {
121        crate::error::ChronicleError::Json {
122            source: e,
123            location: snafu::Location::default(),
124        }
125    })?;
126    println!("{json}");
127
128    Ok(())
129}
130
131/// Run squash synthesis from explicit source SHAs (for CI).
132fn run_squash_synthesis(git_ops: &CliOps, commit: &str, sources: &str) -> Result<()> {
133    let source_shas: Vec<String> = sources
134        .split(',')
135        .map(|s| s.trim().to_string())
136        .filter(|s| !s.is_empty())
137        .collect();
138
139    if source_shas.is_empty() {
140        return Err(crate::error::ChronicleError::Validation {
141            message: "--squash-sources requires at least one source SHA".to_string(),
142            location: snafu::Location::default(),
143        });
144    }
145
146    // Resolve the commit SHA
147    let resolved_commit = git_ops.resolve_ref(commit).context(GitSnafu)?;
148
149    // Collect source annotations and messages
150    let source_ann_pairs = collect_source_annotations(git_ops, &source_shas);
151    let source_annotations: Vec<_> = source_ann_pairs
152        .into_iter()
153        .filter_map(|(_, ann)| ann)
154        .collect();
155    let source_messages = collect_source_messages(git_ops, &source_shas);
156
157    // Get squash commit info
158    let commit_info = git_ops.commit_info(&resolved_commit).context(GitSnafu)?;
159
160    let ctx = SquashSynthesisContext {
161        squash_commit: resolved_commit.clone(),
162        diff: String::new(), // MVP: not used for deterministic merge
163        source_annotations,
164        source_messages,
165        squash_message: commit_info.message,
166    };
167
168    let annotation = synthesize_squash_annotation(&ctx);
169
170    // Write as git note
171    let json = serde_json::to_string_pretty(&annotation).context(JsonSnafu)?;
172    git_ops
173        .note_write(&resolved_commit, &json)
174        .context(GitSnafu)?;
175
176    println!("{json}");
177    Ok(())
178}
179
180/// Run amend migration from an explicit old SHA.
181fn run_amend_migration(git_ops: &CliOps, commit: &str, old_sha: &str) -> Result<()> {
182    let resolved_commit = git_ops.resolve_ref(commit).context(GitSnafu)?;
183
184    // Read old annotation
185    let old_note = git_ops.note_read(old_sha).context(GitSnafu)?;
186    let old_json = match old_note {
187        Some(json) => json,
188        None => {
189            return Err(crate::error::ChronicleError::Validation {
190                message: format!("No annotation found for old commit {old_sha}"),
191                location: snafu::Location::default(),
192            });
193        }
194    };
195
196    let old_annotation: crate::schema::v1::Annotation =
197        serde_json::from_str(&old_json).context(JsonSnafu)?;
198
199    let new_info = git_ops.commit_info(&resolved_commit).context(GitSnafu)?;
200
201    // Compute diff comparison to determine if code changed
202    let new_diffs = git_ops.diff(&resolved_commit).context(GitSnafu)?;
203    let old_diffs = git_ops.diff(old_sha).context(GitSnafu)?;
204    let new_diff_text = format!("{:?}", new_diffs);
205    let old_diff_text = format!("{:?}", old_diffs);
206    let diff_for_migration = if new_diff_text == old_diff_text {
207        String::new()
208    } else {
209        new_diff_text
210    };
211
212    let ctx = AmendMigrationContext {
213        new_commit: resolved_commit.clone(),
214        new_diff: diff_for_migration,
215        old_annotation,
216        new_message: new_info.message,
217    };
218
219    let annotation = migrate_amend_annotation(&ctx);
220
221    let json = serde_json::to_string_pretty(&annotation).context(JsonSnafu)?;
222    git_ops
223        .note_write(&resolved_commit, &json)
224        .context(GitSnafu)?;
225
226    println!("{json}");
227    Ok(())
228}
229
230/// Live annotation path: read v2 JSON from stdin, write annotation. Zero LLM cost.
231fn run_live(git_ops: &CliOps) -> Result<()> {
232    let stdin = std::io::read_to_string(std::io::stdin()).map_err(|e| {
233        crate::error::ChronicleError::Io {
234            source: e,
235            location: snafu::Location::default(),
236        }
237    })?;
238
239    let value: serde_json::Value =
240        serde_json::from_str(&stdin).map_err(|e| crate::error::ChronicleError::Json {
241            source: e,
242            location: snafu::Location::default(),
243        })?;
244
245    if value.get("regions").is_some() {
246        return Err(crate::error::ChronicleError::Validation {
247            message: "v1 annotation format is no longer supported for writing; use v2 format"
248                .to_string(),
249            location: snafu::Location::default(),
250        });
251    }
252
253    let input: crate::annotate::live::LiveInput =
254        serde_json::from_value(value).map_err(|e| crate::error::ChronicleError::Json {
255            source: e,
256            location: snafu::Location::default(),
257        })?;
258
259    let result = crate::annotate::live::handle_annotate_v2(git_ops, input)?;
260    let json =
261        serde_json::to_string_pretty(&result).map_err(|e| crate::error::ChronicleError::Json {
262            source: e,
263            location: snafu::Location::default(),
264        })?;
265    println!("{json}");
266
267    Ok(())
268}