Skip to main content

chronicle/annotate/
live.rs

1use schemars::JsonSchema;
2use serde::de::{SeqAccess, Visitor};
3use serde::{Deserialize, Deserializer, Serialize};
4use snafu::ResultExt;
5
6use crate::error::{chronicle_error, Result};
7use crate::git::GitOps;
8use crate::schema::common::{AstAnchor, LineRange};
9use crate::schema::v2;
10
11// ---------------------------------------------------------------------------
12// Input types (v2 live path)
13// ---------------------------------------------------------------------------
14
15/// Input for the v2 live annotation path.
16///
17/// Designed for minimal friction. Most commits need only two fields:
18/// ```json
19/// { "commit": "HEAD", "summary": "What and why." }
20/// ```
21///
22/// Rich annotation when warranted:
23/// ```json
24/// {
25///   "commit": "HEAD",
26///   "summary": "...",
27///   "motivation": "...",
28///   "rejected_alternatives": [...],
29///   "decisions": [...],
30///   "markers": [...],
31///   "effort": { "id": "...", "description": "...", "phase": "in_progress" }
32/// }
33/// ```
34#[derive(Debug, Clone, Deserialize, JsonSchema)]
35pub struct LiveInput {
36    pub commit: String,
37
38    /// What this commit does and why (becomes narrative.summary).
39    pub summary: String,
40
41    /// What triggered this change? (narrative.motivation)
42    pub motivation: Option<String>,
43
44    /// What alternatives were considered and rejected.
45    /// Accepts either strings ("Tried X but Y") or objects ({"approach": "...", "reason": "..."}).
46    #[serde(default, deserialize_with = "deserialize_flexible_alternatives")]
47    #[schemars(with = "Vec<RejectedAlternativeInput>")]
48    pub rejected_alternatives: Vec<RejectedAlternativeInput>,
49
50    /// Expected follow-up work (narrative.follow_up).
51    pub follow_up: Option<String>,
52
53    /// Design decisions.
54    #[serde(default)]
55    pub decisions: Vec<DecisionInput>,
56
57    /// Code-level markers.
58    #[serde(default)]
59    pub markers: Vec<MarkerInput>,
60
61    /// Link to broader effort.
62    pub effort: Option<EffortInput>,
63
64    /// Pre-loaded staged notes text (appended to provenance.notes).
65    /// Not part of the user-facing JSON schema; populated by the CLI layer.
66    #[serde(skip)]
67    pub staged_notes: Option<String>,
68}
69
70/// A rejected alternative — accepts either a string or a struct.
71#[derive(Debug, Clone, Deserialize, JsonSchema)]
72pub struct RejectedAlternativeInput {
73    pub approach: String,
74    #[serde(default)]
75    pub reason: String,
76}
77
78/// A design decision from the caller.
79#[derive(Debug, Clone, Deserialize, JsonSchema)]
80pub struct DecisionInput {
81    pub what: String,
82    pub why: String,
83    #[serde(default = "default_stability")]
84    pub stability: v2::Stability,
85    pub revisit_when: Option<String>,
86    #[serde(default)]
87    pub scope: Vec<String>,
88}
89
90fn default_stability() -> v2::Stability {
91    v2::Stability::Provisional
92}
93
94/// A code-level marker from the caller.
95#[derive(Debug, Clone, Deserialize, JsonSchema)]
96pub struct MarkerInput {
97    #[serde(alias = "path")]
98    pub file: String,
99    pub anchor: Option<AnchorInput>,
100    pub lines: Option<LineRange>,
101    pub kind: MarkerKindInput,
102}
103
104/// Simplified anchor for marker input.
105#[derive(Debug, Clone, Deserialize, JsonSchema)]
106pub struct AnchorInput {
107    pub unit_type: String,
108    pub name: String,
109}
110
111/// Marker kind from the caller — flexible string-based tags.
112#[derive(Debug, Clone, Deserialize, JsonSchema)]
113#[serde(rename_all = "snake_case", tag = "type")]
114pub enum MarkerKindInput {
115    Contract {
116        description: String,
117    },
118    Hazard {
119        description: String,
120    },
121    Dependency {
122        target_file: String,
123        target_anchor: String,
124        assumption: String,
125    },
126    Unstable {
127        description: String,
128        revisit_when: String,
129    },
130    Security {
131        description: String,
132    },
133    Performance {
134        description: String,
135    },
136    Deprecated {
137        description: String,
138        replacement: Option<String>,
139    },
140    TechDebt {
141        description: String,
142    },
143    TestCoverage {
144        description: String,
145    },
146}
147
148/// Effort link from the caller.
149#[derive(Debug, Clone, Deserialize, JsonSchema)]
150pub struct EffortInput {
151    pub id: String,
152    pub description: String,
153    #[serde(default = "default_effort_phase")]
154    pub phase: v2::EffortPhase,
155}
156
157fn default_effort_phase() -> v2::EffortPhase {
158    v2::EffortPhase::InProgress
159}
160
161// ---------------------------------------------------------------------------
162// Flexible deserialization helpers
163// ---------------------------------------------------------------------------
164
165/// Accepts rejected_alternatives as either:
166/// - strings: "Tried X but Y" -> { approach: "Tried X but Y", reason: "" }
167/// - structs: { approach: "...", reason: "..." }
168fn deserialize_flexible_alternatives<'de, D>(
169    deserializer: D,
170) -> std::result::Result<Vec<RejectedAlternativeInput>, D::Error>
171where
172    D: Deserializer<'de>,
173{
174    struct FlexibleAlternativesVisitor;
175
176    impl<'de> Visitor<'de> for FlexibleAlternativesVisitor {
177        type Value = Vec<RejectedAlternativeInput>;
178
179        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
180            formatter.write_str("a list of strings or {\"approach\": \"...\", \"reason\": \"...\"}")
181        }
182
183        fn visit_seq<S>(
184            self,
185            mut seq: S,
186        ) -> std::result::Result<Vec<RejectedAlternativeInput>, S::Error>
187        where
188            S: SeqAccess<'de>,
189        {
190            let mut items = Vec::new();
191            while let Some(item) = seq.next_element::<FlexibleAlternative>()? {
192                items.push(item.into());
193            }
194            Ok(items)
195        }
196    }
197
198    deserializer.deserialize_seq(FlexibleAlternativesVisitor)
199}
200
201#[derive(Debug, Clone, Deserialize)]
202#[serde(untagged)]
203enum FlexibleAlternative {
204    Struct {
205        approach: String,
206        #[serde(default)]
207        reason: String,
208    },
209    Plain(String),
210}
211
212impl From<FlexibleAlternative> for RejectedAlternativeInput {
213    fn from(fa: FlexibleAlternative) -> Self {
214        match fa {
215            FlexibleAlternative::Struct { approach, reason } => {
216                RejectedAlternativeInput { approach, reason }
217            }
218            FlexibleAlternative::Plain(text) => RejectedAlternativeInput {
219                approach: text,
220                reason: String::new(),
221            },
222        }
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Output types
228// ---------------------------------------------------------------------------
229
230/// Result returned after writing a v2 annotation.
231#[derive(Debug, Clone, Serialize)]
232pub struct LiveResult {
233    pub success: bool,
234    pub commit: String,
235    pub markers_written: usize,
236    pub warnings: Vec<String>,
237}
238
239// ---------------------------------------------------------------------------
240// Quality checks (non-blocking warnings)
241// ---------------------------------------------------------------------------
242
243fn check_quality(input: &LiveInput, files_changed: &[String], commit_message: &str) -> Vec<String> {
244    let mut warnings = Vec::new();
245
246    if input.summary.len() < 20 {
247        warnings.push("Summary is very short — consider adding more detail".to_string());
248    }
249
250    if files_changed.len() > 3 && input.motivation.is_none() {
251        warnings.push("Multi-file change without motivation — consider adding why".to_string());
252    }
253
254    if input.summary.trim() == commit_message.trim() {
255        warnings.push(
256            "Summary matches commit message verbatim — consider adding why this approach was chosen"
257                .to_string(),
258        );
259    }
260
261    if files_changed.len() > 5 && input.decisions.is_empty() {
262        warnings.push(
263            "Large change without decisions — consider documenting key design choices".to_string(),
264        );
265    }
266
267    warnings
268}
269
270// ---------------------------------------------------------------------------
271// Handler
272// ---------------------------------------------------------------------------
273
274/// Core v2 handler: validates input, auto-populates files_changed from diff,
275/// builds and writes a v2 annotation.
276pub fn handle_annotate_v2(git_ops: &dyn GitOps, input: LiveInput) -> Result<LiveResult> {
277    // 1. Resolve commit ref to full SHA
278    let full_sha = git_ops
279        .resolve_ref(&input.commit)
280        .context(chronicle_error::GitSnafu)?;
281
282    // 2. Check for existing note (warn before overwriting)
283    let mut warnings = Vec::new();
284    if git_ops
285        .note_exists(&full_sha)
286        .context(chronicle_error::GitSnafu)?
287    {
288        warnings.push(format!(
289            "Overwriting existing annotation for {}",
290            &full_sha[..full_sha.len().min(8)]
291        ));
292    }
293
294    // 3. Auto-populate files_changed from diff
295    let files_changed = {
296        let diffs = git_ops.diff(&full_sha).context(chronicle_error::GitSnafu)?;
297        diffs.into_iter().map(|d| d.path).collect::<Vec<_>>()
298    };
299
300    // 4. Quality warnings (non-blocking)
301    let commit_message = git_ops
302        .commit_info(&full_sha)
303        .context(chronicle_error::GitSnafu)?
304        .message;
305    warnings.extend(check_quality(&input, &files_changed, &commit_message));
306
307    // 5. Build markers
308    let mut markers = Vec::new();
309    for marker_input in &input.markers {
310        markers.push(build_marker(marker_input));
311    }
312
313    // 6. Build decisions
314    let decisions: Vec<v2::Decision> = input
315        .decisions
316        .iter()
317        .map(|d| v2::Decision {
318            what: d.what.clone(),
319            why: d.why.clone(),
320            stability: d.stability.clone(),
321            revisit_when: d.revisit_when.clone(),
322            scope: d.scope.clone(),
323        })
324        .collect();
325
326    // 7. Build effort link
327    let effort = input.effort.as_ref().map(|e| v2::EffortLink {
328        id: e.id.clone(),
329        description: e.description.clone(),
330        phase: e.phase.clone(),
331    });
332
333    // 8. Build rejected alternatives
334    let rejected_alternatives: Vec<v2::RejectedAlternative> = input
335        .rejected_alternatives
336        .iter()
337        .map(|ra| v2::RejectedAlternative {
338            approach: ra.approach.clone(),
339            reason: ra.reason.clone(),
340        })
341        .collect();
342
343    // 9. Build annotation
344    let annotation = v2::Annotation {
345        schema: "chronicle/v2".to_string(),
346        commit: full_sha.clone(),
347        timestamp: chrono::Utc::now().to_rfc3339(),
348        narrative: v2::Narrative {
349            summary: input.summary.clone(),
350            motivation: input.motivation.clone(),
351            rejected_alternatives,
352            follow_up: input.follow_up.clone(),
353            files_changed,
354        },
355        decisions,
356        markers,
357        effort,
358        provenance: v2::Provenance {
359            source: v2::ProvenanceSource::Live,
360            author: git_ops
361                .config_get("chronicle.author")
362                .ok()
363                .flatten()
364                .or_else(|| git_ops.config_get("user.name").ok().flatten()),
365            derived_from: Vec::new(),
366            notes: input.staged_notes.clone(),
367        },
368    };
369
370    // 10. Validate (reject on structural errors)
371    annotation
372        .validate()
373        .map_err(|msg| crate::error::ChronicleError::Validation {
374            message: msg,
375            location: snafu::Location::new(file!(), line!(), 0),
376        })?;
377
378    // 11. Serialize and write git note
379    let json = serde_json::to_string_pretty(&annotation).context(chronicle_error::JsonSnafu)?;
380    git_ops
381        .note_write(&full_sha, &json)
382        .context(chronicle_error::GitSnafu)?;
383
384    let markers_written = annotation.markers.len();
385
386    Ok(LiveResult {
387        success: true,
388        commit: full_sha,
389        markers_written,
390        warnings,
391    })
392}
393
394/// Build a `CodeMarker` from input, passing through anchor as-is.
395fn build_marker(input: &MarkerInput) -> v2::CodeMarker {
396    let anchor = input.anchor.as_ref().map(|a| AstAnchor {
397        unit_type: a.unit_type.clone(),
398        name: a.name.clone(),
399        signature: None,
400    });
401
402    v2::CodeMarker {
403        file: input.file.clone(),
404        anchor,
405        lines: input.lines,
406        kind: convert_marker_kind(&input.kind),
407    }
408}
409
410fn convert_marker_kind(input: &MarkerKindInput) -> v2::MarkerKind {
411    match input {
412        MarkerKindInput::Contract { description } => v2::MarkerKind::Contract {
413            description: description.clone(),
414            source: v2::ContractSource::Author,
415        },
416        MarkerKindInput::Hazard { description } => v2::MarkerKind::Hazard {
417            description: description.clone(),
418        },
419        MarkerKindInput::Dependency {
420            target_file,
421            target_anchor,
422            assumption,
423        } => v2::MarkerKind::Dependency {
424            target_file: target_file.clone(),
425            target_anchor: target_anchor.clone(),
426            assumption: assumption.clone(),
427        },
428        MarkerKindInput::Unstable {
429            description,
430            revisit_when,
431        } => v2::MarkerKind::Unstable {
432            description: description.clone(),
433            revisit_when: revisit_when.clone(),
434        },
435        MarkerKindInput::Security { description } => v2::MarkerKind::Security {
436            description: description.clone(),
437        },
438        MarkerKindInput::Performance { description } => v2::MarkerKind::Performance {
439            description: description.clone(),
440        },
441        MarkerKindInput::Deprecated {
442            description,
443            replacement,
444        } => v2::MarkerKind::Deprecated {
445            description: description.clone(),
446            replacement: replacement.clone(),
447        },
448        MarkerKindInput::TechDebt { description } => v2::MarkerKind::TechDebt {
449            description: description.clone(),
450        },
451        MarkerKindInput::TestCoverage { description } => v2::MarkerKind::TestCoverage {
452            description: description.clone(),
453        },
454    }
455}
456
457// ---------------------------------------------------------------------------
458// Tests
459// ---------------------------------------------------------------------------
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::error::GitError;
465    use crate::git::diff::{DiffStatus, FileDiff};
466    use crate::git::CommitInfo;
467    use std::collections::HashMap;
468    use std::path::Path;
469    use std::sync::Mutex;
470
471    fn test_diff(path: &str) -> FileDiff {
472        FileDiff {
473            path: path.to_string(),
474            old_path: None,
475            status: DiffStatus::Modified,
476            hunks: vec![],
477        }
478    }
479
480    struct MockGitOps {
481        resolved_sha: String,
482        files: HashMap<String, String>,
483        diffs: Vec<FileDiff>,
484        written_notes: Mutex<Vec<(String, String)>>,
485        note_exists_result: bool,
486        commit_message: String,
487    }
488
489    impl MockGitOps {
490        fn new(sha: &str) -> Self {
491            Self {
492                resolved_sha: sha.to_string(),
493                files: HashMap::new(),
494                diffs: Vec::new(),
495                written_notes: Mutex::new(Vec::new()),
496                note_exists_result: false,
497                commit_message: "test commit".to_string(),
498            }
499        }
500
501        fn with_diffs(mut self, diffs: Vec<FileDiff>) -> Self {
502            self.diffs = diffs;
503            self
504        }
505
506        fn with_note_exists(mut self, exists: bool) -> Self {
507            self.note_exists_result = exists;
508            self
509        }
510
511        fn with_commit_message(mut self, msg: &str) -> Self {
512            self.commit_message = msg.to_string();
513            self
514        }
515
516        fn written_notes(&self) -> Vec<(String, String)> {
517            self.written_notes.lock().unwrap().clone()
518        }
519    }
520
521    impl GitOps for MockGitOps {
522        fn diff(&self, _commit: &str) -> std::result::Result<Vec<FileDiff>, GitError> {
523            Ok(self.diffs.clone())
524        }
525        fn note_read(&self, _commit: &str) -> std::result::Result<Option<String>, GitError> {
526            Ok(None)
527        }
528        fn note_write(&self, commit: &str, content: &str) -> std::result::Result<(), GitError> {
529            self.written_notes
530                .lock()
531                .unwrap()
532                .push((commit.to_string(), content.to_string()));
533            Ok(())
534        }
535        fn note_exists(&self, _commit: &str) -> std::result::Result<bool, GitError> {
536            Ok(self.note_exists_result)
537        }
538        fn file_at_commit(
539            &self,
540            path: &Path,
541            _commit: &str,
542        ) -> std::result::Result<String, GitError> {
543            self.files
544                .get(path.to_str().unwrap_or(""))
545                .cloned()
546                .ok_or(GitError::FileNotFound {
547                    path: path.display().to_string(),
548                    commit: "test".to_string(),
549                    location: snafu::Location::new(file!(), line!(), 0),
550                })
551        }
552        fn commit_info(&self, _commit: &str) -> std::result::Result<CommitInfo, GitError> {
553            Ok(CommitInfo {
554                sha: self.resolved_sha.clone(),
555                message: self.commit_message.clone(),
556                author_name: "Test".to_string(),
557                author_email: "test@test.com".to_string(),
558                timestamp: "2025-01-01T00:00:00Z".to_string(),
559                parent_shas: Vec::new(),
560            })
561        }
562        fn resolve_ref(&self, _refspec: &str) -> std::result::Result<String, GitError> {
563            Ok(self.resolved_sha.clone())
564        }
565        fn config_get(&self, _key: &str) -> std::result::Result<Option<String>, GitError> {
566            Ok(None)
567        }
568        fn config_set(&self, _key: &str, _value: &str) -> std::result::Result<(), GitError> {
569            Ok(())
570        }
571        fn log_for_file(&self, _path: &str) -> std::result::Result<Vec<String>, GitError> {
572            Ok(vec![])
573        }
574        fn list_annotated_commits(
575            &self,
576            _limit: u32,
577        ) -> std::result::Result<Vec<String>, GitError> {
578            Ok(vec![])
579        }
580    }
581
582    #[test]
583    fn test_minimal_input() {
584        let json =
585            r#"{"commit": "HEAD", "summary": "Switch to exponential backoff for MQTT reconnect"}"#;
586        let input: LiveInput = serde_json::from_str(json).unwrap();
587        assert_eq!(input.commit, "HEAD");
588        assert!(input.markers.is_empty());
589        assert!(input.decisions.is_empty());
590        assert!(input.effort.is_none());
591        assert!(input.rejected_alternatives.is_empty());
592    }
593
594    #[test]
595    fn test_rich_input() {
596        let json = r#"{
597            "commit": "HEAD",
598            "summary": "Redesign annotation schema",
599            "motivation": "Current annotations restate diffs",
600            "rejected_alternatives": [
601                {"approach": "Enrich v1 with optional fields", "reason": "Too noisy"},
602                "Tried migrating all notes in bulk"
603            ],
604            "decisions": [
605                {"what": "Lazy migration", "why": "Avoids risky bulk rewrite", "stability": "permanent"}
606            ],
607            "markers": [
608                {"file": "src/schema/v2.rs", "anchor": {"unit_type": "function", "name": "validate"}, "kind": {"type": "contract", "description": "Must be called before writing"}}
609            ],
610            "effort": {"id": "schema-v2", "description": "Schema v2 redesign", "phase": "start"}
611        }"#;
612
613        let input: LiveInput = serde_json::from_str(json).unwrap();
614        assert_eq!(input.rejected_alternatives.len(), 2);
615        assert_eq!(
616            input.rejected_alternatives[0].approach,
617            "Enrich v1 with optional fields"
618        );
619        assert_eq!(
620            input.rejected_alternatives[1].approach,
621            "Tried migrating all notes in bulk"
622        );
623        assert_eq!(input.decisions.len(), 1);
624        assert_eq!(input.decisions[0].stability, v2::Stability::Permanent);
625        assert_eq!(input.markers.len(), 1);
626        assert!(input.effort.is_some());
627        assert_eq!(input.effort.as_ref().unwrap().phase, v2::EffortPhase::Start);
628    }
629
630    #[test]
631    fn test_handle_annotate_v2_minimal() {
632        let mock = MockGitOps::new("abc123def456").with_diffs(vec![test_diff("src/lib.rs")]);
633
634        let input = LiveInput {
635            commit: "HEAD".to_string(),
636            summary: "Add hello_world function and Config struct".to_string(),
637            motivation: None,
638            rejected_alternatives: vec![],
639            follow_up: None,
640            decisions: vec![],
641            markers: vec![],
642            effort: None,
643            staged_notes: None,
644        };
645
646        let result = handle_annotate_v2(&mock, input).unwrap();
647        assert!(result.success);
648        assert_eq!(result.commit, "abc123def456");
649        assert_eq!(result.markers_written, 0);
650
651        let notes = mock.written_notes();
652        assert_eq!(notes.len(), 1);
653        let annotation: v2::Annotation = serde_json::from_str(&notes[0].1).unwrap();
654        assert_eq!(annotation.schema, "chronicle/v2");
655        assert_eq!(
656            annotation.narrative.summary,
657            "Add hello_world function and Config struct"
658        );
659        assert_eq!(annotation.narrative.files_changed, vec!["src/lib.rs"]);
660        assert_eq!(annotation.provenance.source, v2::ProvenanceSource::Live);
661    }
662
663    #[test]
664    fn test_handle_annotate_v2_with_markers() {
665        let mock = MockGitOps::new("abc123").with_diffs(vec![test_diff("src/lib.rs")]);
666
667        let input = LiveInput {
668            commit: "HEAD".to_string(),
669            summary: "Add hello_world function and Config struct".to_string(),
670            motivation: None,
671            rejected_alternatives: vec![],
672            follow_up: None,
673            decisions: vec![],
674            markers: vec![MarkerInput {
675                file: "src/lib.rs".to_string(),
676                anchor: Some(AnchorInput {
677                    unit_type: "function".to_string(),
678                    name: "hello_world".to_string(),
679                }),
680                lines: Some(LineRange { start: 2, end: 4 }),
681                kind: MarkerKindInput::Contract {
682                    description: "Must print to stdout".to_string(),
683                },
684            }],
685            effort: None,
686            staged_notes: None,
687        };
688
689        let result = handle_annotate_v2(&mock, input).unwrap();
690        assert!(result.success);
691        assert_eq!(result.markers_written, 1);
692
693        let notes = mock.written_notes();
694        let annotation: v2::Annotation = serde_json::from_str(&notes[0].1).unwrap();
695        assert_eq!(annotation.markers.len(), 1);
696        assert!(annotation.markers[0].anchor.is_some());
697    }
698
699    #[test]
700    fn test_files_changed_auto_populated() {
701        let mock = MockGitOps::new("abc123")
702            .with_diffs(vec![test_diff("src/lib.rs"), test_diff("src/main.rs")]);
703
704        let input = LiveInput {
705            commit: "HEAD".to_string(),
706            summary: "Multi-file change for testing auto-population".to_string(),
707            motivation: None,
708            rejected_alternatives: vec![],
709            follow_up: None,
710            decisions: vec![],
711            markers: vec![],
712            effort: None,
713            staged_notes: None,
714        };
715
716        let result = handle_annotate_v2(&mock, input).unwrap();
717        assert!(result.success);
718
719        let notes = mock.written_notes();
720        let annotation: v2::Annotation = serde_json::from_str(&notes[0].1).unwrap();
721        assert_eq!(
722            annotation.narrative.files_changed,
723            vec!["src/lib.rs", "src/main.rs"]
724        );
725    }
726
727    #[test]
728    fn test_validation_rejects_empty_summary() {
729        let mock = MockGitOps::new("abc123");
730
731        let input = LiveInput {
732            commit: "HEAD".to_string(),
733            summary: "".to_string(),
734            motivation: None,
735            rejected_alternatives: vec![],
736            follow_up: None,
737            decisions: vec![],
738            markers: vec![],
739            effort: None,
740            staged_notes: None,
741        };
742
743        let result = handle_annotate_v2(&mock, input);
744        assert!(result.is_err());
745    }
746
747    #[test]
748    fn test_effort_defaults_to_in_progress() {
749        let json = r#"{
750            "commit": "HEAD",
751            "summary": "Test effort defaults",
752            "effort": {"id": "test-1", "description": "Test effort"}
753        }"#;
754
755        let input: LiveInput = serde_json::from_str(json).unwrap();
756        assert_eq!(
757            input.effort.as_ref().unwrap().phase,
758            v2::EffortPhase::InProgress
759        );
760    }
761
762    #[test]
763    fn test_decision_defaults_to_provisional() {
764        let json = r#"{
765            "commit": "HEAD",
766            "summary": "Test decision defaults",
767            "decisions": [{"what": "Use X", "why": "Because Y"}]
768        }"#;
769
770        let input: LiveInput = serde_json::from_str(json).unwrap();
771        assert_eq!(input.decisions[0].stability, v2::Stability::Provisional);
772    }
773
774    #[test]
775    fn test_overwrite_existing_note_warns() {
776        let mock = MockGitOps::new("abc123de")
777            .with_diffs(vec![test_diff("src/lib.rs")])
778            .with_note_exists(true);
779
780        let input = LiveInput {
781            commit: "HEAD".to_string(),
782            summary: "Add hello_world function and Config struct".to_string(),
783            motivation: None,
784            rejected_alternatives: vec![],
785            follow_up: None,
786            decisions: vec![],
787            markers: vec![],
788            effort: None,
789            staged_notes: None,
790        };
791
792        let result = handle_annotate_v2(&mock, input).unwrap();
793        assert!(result.success);
794        assert!(
795            result
796                .warnings
797                .iter()
798                .any(|w| w.contains("Overwriting existing annotation")),
799            "Expected overwrite warning, got: {:?}",
800            result.warnings
801        );
802    }
803
804    #[test]
805    fn test_no_overwrite_warning_when_no_existing_note() {
806        let mock = MockGitOps::new("abc123def456").with_diffs(vec![test_diff("src/lib.rs")]);
807
808        let input = LiveInput {
809            commit: "HEAD".to_string(),
810            summary: "Add hello_world function and Config struct".to_string(),
811            motivation: None,
812            rejected_alternatives: vec![],
813            follow_up: None,
814            decisions: vec![],
815            markers: vec![],
816            effort: None,
817            staged_notes: None,
818        };
819
820        let result = handle_annotate_v2(&mock, input).unwrap();
821        assert!(
822            !result.warnings.iter().any(|w| w.contains("Overwriting")),
823            "Should not have overwrite warning: {:?}",
824            result.warnings
825        );
826    }
827
828    #[test]
829    fn test_quality_multi_file_without_motivation() {
830        let mock = MockGitOps::new("abc123def456").with_diffs(vec![
831            test_diff("src/a.rs"),
832            test_diff("src/b.rs"),
833            test_diff("src/c.rs"),
834            test_diff("src/d.rs"),
835        ]);
836
837        let input = LiveInput {
838            commit: "HEAD".to_string(),
839            summary: "Refactor multiple modules for consistency".to_string(),
840            motivation: None,
841            rejected_alternatives: vec![],
842            follow_up: None,
843            decisions: vec![],
844            markers: vec![],
845            effort: None,
846            staged_notes: None,
847        };
848
849        let result = handle_annotate_v2(&mock, input).unwrap();
850        assert!(
851            result
852                .warnings
853                .iter()
854                .any(|w| w.contains("Multi-file change without motivation")),
855            "Expected multi-file motivation warning, got: {:?}",
856            result.warnings
857        );
858    }
859
860    #[test]
861    fn test_quality_summary_matches_commit_message() {
862        let mock = MockGitOps::new("abc123def456")
863            .with_diffs(vec![test_diff("src/lib.rs")])
864            .with_commit_message("Fix the bug in parser");
865
866        let input = LiveInput {
867            commit: "HEAD".to_string(),
868            summary: "Fix the bug in parser".to_string(),
869            motivation: None,
870            rejected_alternatives: vec![],
871            follow_up: None,
872            decisions: vec![],
873            markers: vec![],
874            effort: None,
875            staged_notes: None,
876        };
877
878        let result = handle_annotate_v2(&mock, input).unwrap();
879        assert!(
880            result
881                .warnings
882                .iter()
883                .any(|w| w.contains("Summary matches commit message verbatim")),
884            "Expected verbatim summary warning, got: {:?}",
885            result.warnings
886        );
887    }
888
889    #[test]
890    fn test_quality_large_change_without_decisions() {
891        let mock = MockGitOps::new("abc123def456").with_diffs(vec![
892            test_diff("src/a.rs"),
893            test_diff("src/b.rs"),
894            test_diff("src/c.rs"),
895            test_diff("src/d.rs"),
896            test_diff("src/e.rs"),
897            test_diff("src/f.rs"),
898        ]);
899
900        let input = LiveInput {
901            commit: "HEAD".to_string(),
902            summary: "Large refactor across many modules for improved architecture".to_string(),
903            motivation: Some("Needed for the next feature".to_string()),
904            rejected_alternatives: vec![],
905            follow_up: None,
906            decisions: vec![],
907            markers: vec![],
908            effort: None,
909            staged_notes: None,
910        };
911
912        let result = handle_annotate_v2(&mock, input).unwrap();
913        assert!(
914            result
915                .warnings
916                .iter()
917                .any(|w| w.contains("Large change without decisions")),
918            "Expected large-change decisions warning, got: {:?}",
919            result.warnings
920        );
921    }
922
923    #[test]
924    fn test_marker_without_anchor() {
925        let mock = MockGitOps::new("abc123").with_diffs(vec![test_diff("config.toml")]);
926
927        let input = LiveInput {
928            commit: "HEAD".to_string(),
929            summary: "Update config with new settings for testing".to_string(),
930            motivation: None,
931            rejected_alternatives: vec![],
932            follow_up: None,
933            decisions: vec![],
934            markers: vec![MarkerInput {
935                file: "config.toml".to_string(),
936                anchor: None,
937                lines: None,
938                kind: MarkerKindInput::Hazard {
939                    description: "Config format is not validated at startup".to_string(),
940                },
941            }],
942            effort: None,
943            staged_notes: None,
944        };
945
946        let result = handle_annotate_v2(&mock, input).unwrap();
947        assert!(result.success);
948        assert_eq!(result.markers_written, 1);
949    }
950
951    #[test]
952    fn test_new_marker_kinds_roundtrip() {
953        let json = r#"{
954            "commit": "HEAD",
955            "summary": "Test all new marker kinds for round-trip serialization",
956            "markers": [
957                {"file": "src/auth.rs", "kind": {"type": "security", "description": "Validates JWT tokens"}},
958                {"file": "src/hot.rs", "kind": {"type": "performance", "description": "Hot loop, avoid allocations"}},
959                {"file": "src/old.rs", "kind": {"type": "deprecated", "description": "Use new_api instead", "replacement": "src/new_api.rs"}},
960                {"file": "src/hack.rs", "kind": {"type": "tech_debt", "description": "Needs refactor after v2 ships"}},
961                {"file": "src/lib.rs", "kind": {"type": "test_coverage", "description": "Missing edge case tests for empty input"}}
962            ]
963        }"#;
964
965        let input: LiveInput = serde_json::from_str(json).unwrap();
966        assert_eq!(input.markers.len(), 5);
967
968        let mock = MockGitOps::new("abc123").with_diffs(vec![
969            test_diff("src/auth.rs"),
970            test_diff("src/hot.rs"),
971            test_diff("src/old.rs"),
972            test_diff("src/hack.rs"),
973            test_diff("src/lib.rs"),
974        ]);
975
976        let result = handle_annotate_v2(&mock, input).unwrap();
977        assert!(result.success);
978        assert_eq!(result.markers_written, 5);
979
980        let notes = mock.written_notes();
981        let annotation: v2::Annotation = serde_json::from_str(&notes[0].1).unwrap();
982        assert_eq!(annotation.markers.len(), 5);
983
984        assert!(matches!(
985            &annotation.markers[0].kind,
986            v2::MarkerKind::Security { description } if description == "Validates JWT tokens"
987        ));
988        assert!(matches!(
989            &annotation.markers[1].kind,
990            v2::MarkerKind::Performance { description } if description == "Hot loop, avoid allocations"
991        ));
992        assert!(matches!(
993            &annotation.markers[2].kind,
994            v2::MarkerKind::Deprecated { description, replacement }
995                if description == "Use new_api instead" && replacement.as_deref() == Some("src/new_api.rs")
996        ));
997        assert!(matches!(
998            &annotation.markers[3].kind,
999            v2::MarkerKind::TechDebt { description } if description == "Needs refactor after v2 ships"
1000        ));
1001        assert!(matches!(
1002            &annotation.markers[4].kind,
1003            v2::MarkerKind::TestCoverage { description } if description == "Missing edge case tests for empty input"
1004        ));
1005    }
1006
1007    #[test]
1008    fn test_deprecated_marker_without_replacement() {
1009        let json = r#"{
1010            "commit": "HEAD",
1011            "summary": "Test deprecated marker without replacement field",
1012            "markers": [
1013                {"file": "src/old.rs", "kind": {"type": "deprecated", "description": "Will be removed in v3"}}
1014            ]
1015        }"#;
1016
1017        let input: LiveInput = serde_json::from_str(json).unwrap();
1018        let mock = MockGitOps::new("abc123").with_diffs(vec![test_diff("src/old.rs")]);
1019
1020        let result = handle_annotate_v2(&mock, input).unwrap();
1021        assert!(result.success);
1022
1023        let notes = mock.written_notes();
1024        let annotation: v2::Annotation = serde_json::from_str(&notes[0].1).unwrap();
1025        assert!(matches!(
1026            &annotation.markers[0].kind,
1027            v2::MarkerKind::Deprecated { replacement, .. } if replacement.is_none()
1028        ));
1029    }
1030}