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#[derive(Debug, Clone, Deserialize, JsonSchema)]
35pub struct LiveInput {
36 pub commit: String,
37
38 pub summary: String,
40
41 pub motivation: Option<String>,
43
44 #[serde(default, deserialize_with = "deserialize_flexible_alternatives")]
47 #[schemars(with = "Vec<RejectedAlternativeInput>")]
48 pub rejected_alternatives: Vec<RejectedAlternativeInput>,
49
50 pub follow_up: Option<String>,
52
53 #[serde(default)]
55 pub decisions: Vec<DecisionInput>,
56
57 #[serde(default)]
59 pub markers: Vec<MarkerInput>,
60
61 pub effort: Option<EffortInput>,
63
64 #[serde(skip)]
67 pub staged_notes: Option<String>,
68}
69
70#[derive(Debug, Clone, Deserialize, JsonSchema)]
72pub struct RejectedAlternativeInput {
73 pub approach: String,
74 #[serde(default)]
75 pub reason: String,
76}
77
78#[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#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
106pub struct AnchorInput {
107 pub unit_type: String,
108 pub name: String,
109}
110
111#[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#[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
161fn 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#[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
239fn 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
270pub fn handle_annotate_v2(git_ops: &dyn GitOps, input: LiveInput) -> Result<LiveResult> {
277 let full_sha = git_ops
279 .resolve_ref(&input.commit)
280 .context(chronicle_error::GitSnafu)?;
281
282 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 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 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 let mut markers = Vec::new();
309 for marker_input in &input.markers {
310 markers.push(build_marker(marker_input));
311 }
312
313 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 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 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 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 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 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
394fn 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#[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(¬es[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(¬es[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(¬es[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(¬es[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(¬es[0].1).unwrap();
1025 assert!(matches!(
1026 &annotation.markers[0].kind,
1027 v2::MarkerKind::Deprecated { replacement, .. } if replacement.is_none()
1028 ));
1029 }
1030}