Skip to main content

chronicle/annotate/
live.rs

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// ---------------------------------------------------------------------------
11// Input types (v3 live path)
12// ---------------------------------------------------------------------------
13
14/// Input for the v3 live annotation path.
15///
16/// Designed for minimal friction. Most commits need only two fields:
17/// ```json
18/// { "commit": "HEAD", "summary": "What and why." }
19/// ```
20///
21/// Rich annotation when warranted:
22/// ```json
23/// {
24///   "commit": "HEAD",
25///   "summary": "...",
26///   "wisdom": [
27///     {"category": "gotcha", "content": "...", "file": "src/foo.rs"},
28///     {"category": "dead_end", "content": "Tried X but it failed because Y"}
29///   ]
30/// }
31/// ```
32#[derive(Debug, Clone, Deserialize, JsonSchema)]
33pub struct LiveInput {
34    pub commit: String,
35
36    /// What this commit does and why.
37    pub summary: String,
38
39    /// Accumulated wisdom entries — dead ends, gotchas, insights, threads.
40    #[serde(default)]
41    pub wisdom: Vec<WisdomEntryInput>,
42
43    /// Pre-loaded staged notes text (appended to provenance.notes).
44    /// Not part of the user-facing JSON schema; populated by the CLI layer.
45    #[serde(skip)]
46    pub staged_notes: Option<String>,
47}
48
49/// A single wisdom entry from the caller.
50#[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// ---------------------------------------------------------------------------
59// Output types
60// ---------------------------------------------------------------------------
61
62/// Result returned after writing a v3 annotation.
63#[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
71// ---------------------------------------------------------------------------
72// Quality checks (non-blocking warnings)
73// ---------------------------------------------------------------------------
74
75fn 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
98// ---------------------------------------------------------------------------
99// Handler
100// ---------------------------------------------------------------------------
101
102/// Core v3 handler: validates input, builds and writes a v3 annotation.
103pub 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// ---------------------------------------------------------------------------
182// Tests
183// ---------------------------------------------------------------------------
184
185#[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(&notes[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(&notes[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(&notes[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(&notes[0].1).unwrap();
567        assert_eq!(
568            annotation.provenance.notes.as_deref(),
569            Some("staged: some context")
570        );
571    }
572}