Skip to main content

chronicle/cli/
annotate.rs

1use crate::annotate::squash::{
2    collect_source_annotations_v3, collect_source_messages, migrate_amend_annotation,
3    synthesize_squash_annotation_v3, AmendMigrationContext, SquashSynthesisContextV3,
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            wisdom: vec![],
49            staged_notes: staged_notes_text.clone(),
50        };
51        let result = crate::annotate::live::handle_annotate_v3(&git_ops, input)?;
52        let _ = crate::annotate::staging::clear_staged(&git_dir);
53        let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
54        println!("{json}");
55        return Ok(());
56    }
57
58    // --json: full annotation JSON on command line
59    if let Some(json_str) = json_input {
60        let mut input: crate::annotate::live::LiveInput =
61            serde_json::from_str(&json_str).context(JsonSnafu)?;
62        input.staged_notes = staged_notes_text.clone();
63        let result = crate::annotate::live::handle_annotate_v3(&git_ops, input)?;
64        let _ = crate::annotate::staging::clear_staged(&git_dir);
65        let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
66        println!("{json}");
67        return Ok(());
68    }
69
70    // --auto: use commit message as summary
71    if auto {
72        let full_sha = git_ops.resolve_ref(&commit).context(GitSnafu)?;
73        let commit_info = git_ops.commit_info(&full_sha).context(GitSnafu)?;
74        let input = crate::annotate::live::LiveInput {
75            commit,
76            summary: commit_info.message,
77            wisdom: vec![],
78            staged_notes: staged_notes_text.clone(),
79        };
80        let result = crate::annotate::live::handle_annotate_v3(&git_ops, input)?;
81        let _ = crate::annotate::staging::clear_staged(&git_dir);
82        let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
83        println!("{json}");
84        return Ok(());
85    }
86
87    if live {
88        return run_live(&git_ops);
89    }
90
91    // Handle --squash-sources
92    if let Some(sources) = squash_sources {
93        return run_squash_synthesis(&git_ops, &commit, &sources);
94    }
95
96    // Handle --amend-source
97    if let Some(old_sha) = amend_source {
98        return run_amend_migration(&git_ops, &commit, &old_sha);
99    }
100
101    Err(crate::error::ChronicleError::Validation {
102        message: "no annotation mode specified; use --live, --summary, --json, --auto, --squash-sources, or --amend-source".to_string(),
103        location: snafu::Location::default(),
104    })
105}
106
107/// Run squash synthesis from explicit source SHAs (for CI).
108fn run_squash_synthesis(git_ops: &CliOps, commit: &str, sources: &str) -> Result<()> {
109    let source_shas: Vec<String> = sources
110        .split(',')
111        .map(|s| s.trim().to_string())
112        .filter(|s| !s.is_empty())
113        .collect();
114
115    if source_shas.is_empty() {
116        return Err(crate::error::ChronicleError::Validation {
117            message: "--squash-sources requires at least one source SHA".to_string(),
118            location: snafu::Location::default(),
119        });
120    }
121
122    // Resolve the commit SHA
123    let resolved_commit = git_ops.resolve_ref(commit).context(GitSnafu)?;
124
125    // Collect source annotations (v3-normalized) and messages
126    let source_ann_pairs = collect_source_annotations_v3(git_ops, &source_shas);
127    let source_annotations: Vec<_> = source_ann_pairs
128        .into_iter()
129        .filter_map(|(_, ann)| ann)
130        .collect();
131    let source_messages = collect_source_messages(git_ops, &source_shas);
132
133    // Get squash commit info
134    let commit_info = git_ops.commit_info(&resolved_commit).context(GitSnafu)?;
135
136    let ctx = SquashSynthesisContextV3 {
137        squash_commit: resolved_commit.clone(),
138        source_annotations,
139        source_messages,
140        squash_message: commit_info.message,
141    };
142
143    let annotation = synthesize_squash_annotation_v3(&ctx);
144
145    // Write as git note
146    let json = serde_json::to_string_pretty(&annotation).context(JsonSnafu)?;
147    git_ops
148        .note_write(&resolved_commit, &json)
149        .context(GitSnafu)?;
150
151    println!("{json}");
152    Ok(())
153}
154
155/// Run amend migration from an explicit old SHA.
156fn run_amend_migration(git_ops: &CliOps, commit: &str, old_sha: &str) -> Result<()> {
157    let resolved_commit = git_ops.resolve_ref(commit).context(GitSnafu)?;
158
159    // Read old annotation
160    let old_note = git_ops.note_read(old_sha).context(GitSnafu)?;
161    let old_json = match old_note {
162        Some(json) => json,
163        None => {
164            return Err(crate::error::ChronicleError::Validation {
165                message: format!("No annotation found for old commit {old_sha}"),
166                location: snafu::Location::default(),
167            });
168        }
169    };
170
171    let old_annotation: crate::schema::v1::Annotation =
172        serde_json::from_str(&old_json).context(JsonSnafu)?;
173
174    let new_info = git_ops.commit_info(&resolved_commit).context(GitSnafu)?;
175
176    // Compute diff comparison to determine if code changed
177    let new_diffs = git_ops.diff(&resolved_commit).context(GitSnafu)?;
178    let old_diffs = git_ops.diff(old_sha).context(GitSnafu)?;
179    let new_diff_text = format!("{:?}", new_diffs);
180    let old_diff_text = format!("{:?}", old_diffs);
181    let diff_for_migration = if new_diff_text == old_diff_text {
182        String::new()
183    } else {
184        new_diff_text
185    };
186
187    let ctx = AmendMigrationContext {
188        new_commit: resolved_commit.clone(),
189        new_diff: diff_for_migration,
190        old_annotation,
191        new_message: new_info.message,
192    };
193
194    let annotation = migrate_amend_annotation(&ctx);
195
196    let json = serde_json::to_string_pretty(&annotation).context(JsonSnafu)?;
197    git_ops
198        .note_write(&resolved_commit, &json)
199        .context(GitSnafu)?;
200
201    println!("{json}");
202    Ok(())
203}
204
205/// Live annotation path: read v3 JSON from stdin, write annotation. Zero LLM cost.
206fn run_live(git_ops: &CliOps) -> Result<()> {
207    let stdin = std::io::read_to_string(std::io::stdin()).map_err(|e| {
208        crate::error::ChronicleError::Io {
209            source: e,
210            location: snafu::Location::default(),
211        }
212    })?;
213
214    let value: serde_json::Value =
215        serde_json::from_str(&stdin).map_err(|e| crate::error::ChronicleError::Json {
216            source: e,
217            location: snafu::Location::default(),
218        })?;
219
220    if value.get("regions").is_some() {
221        return Err(crate::error::ChronicleError::Validation {
222            message: "v1 annotation format is no longer supported for writing; use v3 format"
223                .to_string(),
224            location: snafu::Location::default(),
225        });
226    }
227
228    let input: crate::annotate::live::LiveInput =
229        serde_json::from_value(value).map_err(|e| crate::error::ChronicleError::Json {
230            source: e,
231            location: snafu::Location::default(),
232        })?;
233
234    let result = crate::annotate::live::handle_annotate_v3(git_ops, input)?;
235    let json =
236        serde_json::to_string_pretty(&result).map_err(|e| crate::error::ChronicleError::Json {
237            source: e,
238            location: snafu::Location::default(),
239        })?;
240    println!("{json}");
241
242    Ok(())
243}