1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use snafu::ResultExt;
4
5use crate::error::{chronicle_error, Result};
6use crate::git::GitOps;
7use crate::schema::common::LineRange;
8use crate::schema::v3;
9
10#[derive(Debug, Clone, Deserialize, JsonSchema)]
33pub struct LiveInput {
34 pub commit: String,
35
36 pub summary: String,
38
39 #[serde(default)]
41 pub wisdom: Vec<WisdomEntryInput>,
42
43 #[serde(skip)]
46 pub staged_notes: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, JsonSchema)]
51pub struct WisdomEntryInput {
52 pub category: v3::WisdomCategory,
53 pub content: String,
54 pub file: Option<String>,
55 pub lines: Option<LineRange>,
56}
57
58#[derive(Debug, Clone, Serialize)]
64pub struct LiveResult {
65 pub success: bool,
66 pub commit: String,
67 pub wisdom_written: usize,
68 pub warnings: Vec<String>,
69}
70
71fn check_quality(input: &LiveInput, files_changed: &[String], commit_message: &str) -> Vec<String> {
76 let mut warnings = Vec::new();
77
78 if input.summary.len() < 20 {
79 warnings.push("Summary is very short — consider adding more detail".to_string());
80 }
81
82 if files_changed.len() > 3 && input.wisdom.is_empty() {
83 warnings.push(
84 "Multi-file change without wisdom — consider adding gotchas or insights".to_string(),
85 );
86 }
87
88 if input.summary.trim() == commit_message.trim() {
89 warnings.push(
90 "Summary matches commit message verbatim — consider adding why this approach was chosen"
91 .to_string(),
92 );
93 }
94
95 warnings
96}
97
98pub fn handle_annotate_v3(git_ops: &dyn GitOps, input: LiveInput) -> Result<LiveResult> {
104 let full_sha = git_ops
105 .resolve_ref(&input.commit)
106 .context(chronicle_error::GitSnafu)?;
107
108 let mut warnings = Vec::new();
109 if git_ops
110 .note_exists(&full_sha)
111 .context(chronicle_error::GitSnafu)?
112 {
113 warnings.push(format!(
114 "Overwriting existing annotation for {}",
115 &full_sha[..full_sha.len().min(8)]
116 ));
117 }
118
119 let files_changed = {
120 let diffs = git_ops.diff(&full_sha).context(chronicle_error::GitSnafu)?;
121 diffs.into_iter().map(|d| d.path).collect::<Vec<_>>()
122 };
123
124 let commit_message = git_ops
125 .commit_info(&full_sha)
126 .context(chronicle_error::GitSnafu)?
127 .message;
128 warnings.extend(check_quality(&input, &files_changed, &commit_message));
129
130 let wisdom: Vec<v3::WisdomEntry> = input
131 .wisdom
132 .iter()
133 .map(|w| v3::WisdomEntry {
134 category: w.category.clone(),
135 content: w.content.clone(),
136 file: w.file.clone(),
137 lines: w.lines,
138 })
139 .collect();
140
141 let wisdom_count = wisdom.len();
142
143 let annotation = v3::Annotation {
144 schema: "chronicle/v3".to_string(),
145 commit: full_sha.clone(),
146 timestamp: chrono::Utc::now().to_rfc3339(),
147 summary: input.summary.clone(),
148 wisdom,
149 provenance: v3::Provenance {
150 source: v3::ProvenanceSource::Live,
151 author: git_ops
152 .config_get("chronicle.author")
153 .ok()
154 .flatten()
155 .or_else(|| git_ops.config_get("user.name").ok().flatten()),
156 derived_from: Vec::new(),
157 notes: input.staged_notes.clone(),
158 },
159 };
160
161 annotation
162 .validate()
163 .map_err(|msg| crate::error::ChronicleError::Validation {
164 message: msg,
165 location: snafu::Location::new(file!(), line!(), 0),
166 })?;
167
168 let json = serde_json::to_string_pretty(&annotation).context(chronicle_error::JsonSnafu)?;
169 git_ops
170 .note_write(&full_sha, &json)
171 .context(chronicle_error::GitSnafu)?;
172
173 Ok(LiveResult {
174 success: true,
175 commit: full_sha,
176 wisdom_written: wisdom_count,
177 warnings,
178 })
179}
180
181#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::error::GitError;
189 use crate::git::diff::{DiffStatus, FileDiff};
190 use crate::git::CommitInfo;
191 use std::collections::HashMap;
192 use std::path::Path;
193 use std::sync::Mutex;
194
195 fn test_diff(path: &str) -> FileDiff {
196 FileDiff {
197 path: path.to_string(),
198 old_path: None,
199 status: DiffStatus::Modified,
200 hunks: vec![],
201 }
202 }
203
204 struct MockGitOps {
205 resolved_sha: String,
206 files: HashMap<String, String>,
207 diffs: Vec<FileDiff>,
208 written_notes: Mutex<Vec<(String, String)>>,
209 note_exists_result: bool,
210 commit_message: String,
211 }
212
213 impl MockGitOps {
214 fn new(sha: &str) -> Self {
215 Self {
216 resolved_sha: sha.to_string(),
217 files: HashMap::new(),
218 diffs: Vec::new(),
219 written_notes: Mutex::new(Vec::new()),
220 note_exists_result: false,
221 commit_message: "test commit".to_string(),
222 }
223 }
224
225 fn with_diffs(mut self, diffs: Vec<FileDiff>) -> Self {
226 self.diffs = diffs;
227 self
228 }
229
230 fn with_note_exists(mut self, exists: bool) -> Self {
231 self.note_exists_result = exists;
232 self
233 }
234
235 fn with_commit_message(mut self, msg: &str) -> Self {
236 self.commit_message = msg.to_string();
237 self
238 }
239
240 fn written_notes(&self) -> Vec<(String, String)> {
241 self.written_notes.lock().unwrap().clone()
242 }
243 }
244
245 impl GitOps for MockGitOps {
246 fn diff(&self, _commit: &str) -> std::result::Result<Vec<FileDiff>, GitError> {
247 Ok(self.diffs.clone())
248 }
249 fn note_read(&self, _commit: &str) -> std::result::Result<Option<String>, GitError> {
250 Ok(None)
251 }
252 fn note_write(&self, commit: &str, content: &str) -> std::result::Result<(), GitError> {
253 self.written_notes
254 .lock()
255 .unwrap()
256 .push((commit.to_string(), content.to_string()));
257 Ok(())
258 }
259 fn note_exists(&self, _commit: &str) -> std::result::Result<bool, GitError> {
260 Ok(self.note_exists_result)
261 }
262 fn file_at_commit(
263 &self,
264 path: &Path,
265 _commit: &str,
266 ) -> std::result::Result<String, GitError> {
267 self.files
268 .get(path.to_str().unwrap_or(""))
269 .cloned()
270 .ok_or(GitError::FileNotFound {
271 path: path.display().to_string(),
272 commit: "test".to_string(),
273 location: snafu::Location::new(file!(), line!(), 0),
274 })
275 }
276 fn commit_info(&self, _commit: &str) -> std::result::Result<CommitInfo, GitError> {
277 Ok(CommitInfo {
278 sha: self.resolved_sha.clone(),
279 message: self.commit_message.clone(),
280 author_name: "Test".to_string(),
281 author_email: "test@test.com".to_string(),
282 timestamp: "2025-01-01T00:00:00Z".to_string(),
283 parent_shas: Vec::new(),
284 })
285 }
286 fn resolve_ref(&self, _refspec: &str) -> std::result::Result<String, GitError> {
287 Ok(self.resolved_sha.clone())
288 }
289 fn config_get(&self, _key: &str) -> std::result::Result<Option<String>, GitError> {
290 Ok(None)
291 }
292 fn config_set(&self, _key: &str, _value: &str) -> std::result::Result<(), GitError> {
293 Ok(())
294 }
295 fn log_for_file(&self, _path: &str) -> std::result::Result<Vec<String>, GitError> {
296 Ok(vec![])
297 }
298 fn list_annotated_commits(
299 &self,
300 _limit: u32,
301 ) -> std::result::Result<Vec<String>, GitError> {
302 Ok(vec![])
303 }
304 }
305
306 #[test]
307 fn test_minimal_input() {
308 let json =
309 r#"{"commit": "HEAD", "summary": "Switch to exponential backoff for MQTT reconnect"}"#;
310 let input: LiveInput = serde_json::from_str(json).unwrap();
311 assert_eq!(input.commit, "HEAD");
312 assert!(input.wisdom.is_empty());
313 }
314
315 #[test]
316 fn test_rich_input() {
317 let json = r#"{
318 "commit": "HEAD",
319 "summary": "Redesign annotation schema",
320 "wisdom": [
321 {"category": "dead_end", "content": "Tried migrating all notes in bulk"},
322 {"category": "gotcha", "content": "Must not exceed 60s backoff", "file": "src/reconnect.rs"},
323 {"category": "insight", "content": "HashMap is O(1) for cache lookups", "file": "src/cache.rs", "lines": {"start": 10, "end": 20}},
324 {"category": "unfinished_thread", "content": "Need to add jitter to the backoff"}
325 ]
326 }"#;
327
328 let input: LiveInput = serde_json::from_str(json).unwrap();
329 assert_eq!(input.wisdom.len(), 4);
330 assert_eq!(input.wisdom[0].category, v3::WisdomCategory::DeadEnd);
331 assert_eq!(input.wisdom[1].category, v3::WisdomCategory::Gotcha);
332 assert_eq!(input.wisdom[2].category, v3::WisdomCategory::Insight);
333 assert_eq!(
334 input.wisdom[3].category,
335 v3::WisdomCategory::UnfinishedThread
336 );
337 assert_eq!(input.wisdom[1].file.as_deref(), Some("src/reconnect.rs"));
338 assert_eq!(
339 input.wisdom[2].lines,
340 Some(LineRange { start: 10, end: 20 })
341 );
342 }
343
344 #[test]
345 fn test_handle_annotate_v3_minimal() {
346 let mock = MockGitOps::new("abc123def456").with_diffs(vec![test_diff("src/lib.rs")]);
347
348 let input = LiveInput {
349 commit: "HEAD".to_string(),
350 summary: "Add hello_world function and Config struct".to_string(),
351 wisdom: vec![],
352 staged_notes: None,
353 };
354
355 let result = handle_annotate_v3(&mock, input).unwrap();
356 assert!(result.success);
357 assert_eq!(result.commit, "abc123def456");
358 assert_eq!(result.wisdom_written, 0);
359
360 let notes = mock.written_notes();
361 assert_eq!(notes.len(), 1);
362 let annotation: v3::Annotation = serde_json::from_str(¬es[0].1).unwrap();
363 assert_eq!(annotation.schema, "chronicle/v3");
364 assert_eq!(
365 annotation.summary,
366 "Add hello_world function and Config struct"
367 );
368 assert_eq!(annotation.provenance.source, v3::ProvenanceSource::Live);
369 }
370
371 #[test]
372 fn test_handle_annotate_v3_with_wisdom() {
373 let mock = MockGitOps::new("abc123").with_diffs(vec![test_diff("src/lib.rs")]);
374
375 let input = LiveInput {
376 commit: "HEAD".to_string(),
377 summary: "Add hello_world function and Config struct".to_string(),
378 wisdom: vec![WisdomEntryInput {
379 category: v3::WisdomCategory::Gotcha,
380 content: "Must print to stdout".to_string(),
381 file: Some("src/lib.rs".to_string()),
382 lines: Some(LineRange { start: 2, end: 4 }),
383 }],
384 staged_notes: None,
385 };
386
387 let result = handle_annotate_v3(&mock, input).unwrap();
388 assert!(result.success);
389 assert_eq!(result.wisdom_written, 1);
390
391 let notes = mock.written_notes();
392 let annotation: v3::Annotation = serde_json::from_str(¬es[0].1).unwrap();
393 assert_eq!(annotation.wisdom.len(), 1);
394 assert_eq!(annotation.wisdom[0].category, v3::WisdomCategory::Gotcha);
395 assert_eq!(annotation.wisdom[0].content, "Must print to stdout");
396 assert_eq!(annotation.wisdom[0].file.as_deref(), Some("src/lib.rs"));
397 }
398
399 #[test]
400 fn test_validation_rejects_empty_summary() {
401 let mock = MockGitOps::new("abc123");
402
403 let input = LiveInput {
404 commit: "HEAD".to_string(),
405 summary: "".to_string(),
406 wisdom: vec![],
407 staged_notes: None,
408 };
409
410 let result = handle_annotate_v3(&mock, input);
411 assert!(result.is_err());
412 }
413
414 #[test]
415 fn test_overwrite_existing_note_warns() {
416 let mock = MockGitOps::new("abc123de")
417 .with_diffs(vec![test_diff("src/lib.rs")])
418 .with_note_exists(true);
419
420 let input = LiveInput {
421 commit: "HEAD".to_string(),
422 summary: "Add hello_world function and Config struct".to_string(),
423 wisdom: vec![],
424 staged_notes: None,
425 };
426
427 let result = handle_annotate_v3(&mock, input).unwrap();
428 assert!(result.success);
429 assert!(
430 result
431 .warnings
432 .iter()
433 .any(|w| w.contains("Overwriting existing annotation")),
434 "Expected overwrite warning, got: {:?}",
435 result.warnings
436 );
437 }
438
439 #[test]
440 fn test_no_overwrite_warning_when_no_existing_note() {
441 let mock = MockGitOps::new("abc123def456").with_diffs(vec![test_diff("src/lib.rs")]);
442
443 let input = LiveInput {
444 commit: "HEAD".to_string(),
445 summary: "Add hello_world function and Config struct".to_string(),
446 wisdom: vec![],
447 staged_notes: None,
448 };
449
450 let result = handle_annotate_v3(&mock, input).unwrap();
451 assert!(
452 !result.warnings.iter().any(|w| w.contains("Overwriting")),
453 "Should not have overwrite warning: {:?}",
454 result.warnings
455 );
456 }
457
458 #[test]
459 fn test_quality_multi_file_without_wisdom() {
460 let mock = MockGitOps::new("abc123def456").with_diffs(vec![
461 test_diff("src/a.rs"),
462 test_diff("src/b.rs"),
463 test_diff("src/c.rs"),
464 test_diff("src/d.rs"),
465 ]);
466
467 let input = LiveInput {
468 commit: "HEAD".to_string(),
469 summary: "Refactor multiple modules for consistency".to_string(),
470 wisdom: vec![],
471 staged_notes: None,
472 };
473
474 let result = handle_annotate_v3(&mock, input).unwrap();
475 assert!(
476 result
477 .warnings
478 .iter()
479 .any(|w| w.contains("Multi-file change without wisdom")),
480 "Expected multi-file wisdom warning, got: {:?}",
481 result.warnings
482 );
483 }
484
485 #[test]
486 fn test_quality_summary_matches_commit_message() {
487 let mock = MockGitOps::new("abc123def456")
488 .with_diffs(vec![test_diff("src/lib.rs")])
489 .with_commit_message("Fix the bug in parser");
490
491 let input = LiveInput {
492 commit: "HEAD".to_string(),
493 summary: "Fix the bug in parser".to_string(),
494 wisdom: vec![],
495 staged_notes: None,
496 };
497
498 let result = handle_annotate_v3(&mock, input).unwrap();
499 assert!(
500 result
501 .warnings
502 .iter()
503 .any(|w| w.contains("Summary matches commit message verbatim")),
504 "Expected verbatim summary warning, got: {:?}",
505 result.warnings
506 );
507 }
508
509 #[test]
510 fn test_wisdom_entry_roundtrip() {
511 let json = r#"{
512 "commit": "HEAD",
513 "summary": "Test all wisdom categories for round-trip serialization",
514 "wisdom": [
515 {"category": "dead_end", "content": "Tried approach X"},
516 {"category": "gotcha", "content": "Must validate input before processing", "file": "src/input.rs"},
517 {"category": "insight", "content": "HashMap gives O(1) lookups", "file": "src/cache.rs", "lines": {"start": 10, "end": 20}},
518 {"category": "unfinished_thread", "content": "Need to add jitter"}
519 ]
520 }"#;
521
522 let input: LiveInput = serde_json::from_str(json).unwrap();
523 assert_eq!(input.wisdom.len(), 4);
524
525 let mock = MockGitOps::new("abc123")
526 .with_diffs(vec![test_diff("src/input.rs"), test_diff("src/cache.rs")]);
527
528 let result = handle_annotate_v3(&mock, input).unwrap();
529 assert!(result.success);
530 assert_eq!(result.wisdom_written, 4);
531
532 let notes = mock.written_notes();
533 let annotation: v3::Annotation = serde_json::from_str(¬es[0].1).unwrap();
534 assert_eq!(annotation.wisdom.len(), 4);
535 assert_eq!(annotation.wisdom[0].category, v3::WisdomCategory::DeadEnd);
536 assert_eq!(annotation.wisdom[1].category, v3::WisdomCategory::Gotcha);
537 assert_eq!(annotation.wisdom[2].category, v3::WisdomCategory::Insight);
538 assert_eq!(
539 annotation.wisdom[3].category,
540 v3::WisdomCategory::UnfinishedThread
541 );
542 }
543
544 #[test]
545 fn test_wisdom_default_empty() {
546 let json = r#"{"commit": "HEAD", "summary": "No wisdom provided here at all"}"#;
547 let input: LiveInput = serde_json::from_str(json).unwrap();
548 assert!(input.wisdom.is_empty());
549 }
550
551 #[test]
552 fn test_staged_notes_in_provenance() {
553 let mock = MockGitOps::new("abc123").with_diffs(vec![test_diff("src/lib.rs")]);
554
555 let input = LiveInput {
556 commit: "HEAD".to_string(),
557 summary: "Test that staged notes appear in provenance".to_string(),
558 wisdom: vec![],
559 staged_notes: Some("staged: some context".to_string()),
560 };
561
562 let result = handle_annotate_v3(&mock, input).unwrap();
563 assert!(result.success);
564
565 let notes = mock.written_notes();
566 let annotation: v3::Annotation = serde_json::from_str(¬es[0].1).unwrap();
567 assert_eq!(
568 annotation.provenance.notes.as_deref(),
569 Some("staged: some context")
570 );
571 }
572}