1use std::path::Path;
2
3use serde::de::{SeqAccess, Visitor};
4use serde::{Deserialize, Deserializer, Serialize};
5use snafu::ResultExt;
6
7use crate::ast::{self, AnchorMatch, Language};
8use crate::error::{chronicle_error, Result};
9use crate::git::GitOps;
10use crate::schema::{
11 Annotation, AstAnchor, Constraint, ConstraintSource, ContextLevel, CrossCuttingConcern,
12 LineRange, Provenance, ProvenanceOperation, RegionAnnotation, SemanticDependency,
13};
14
15#[derive(Debug, Clone, Deserialize)]
21pub struct AnnotateInput {
22 pub commit: String,
23 pub summary: String,
24 pub task: Option<String>,
25 pub regions: Vec<RegionInput>,
26 #[serde(default)]
27 pub cross_cutting: Vec<CrossCuttingConcern>,
28}
29
30fn default_line_range() -> LineRange {
33 LineRange { start: 0, end: 0 }
34}
35
36#[derive(Debug, Clone, Deserialize)]
38pub struct RegionInput {
39 #[serde(alias = "path")]
40 pub file: String,
41 #[serde(default)]
42 pub anchor: Option<AnchorInput>,
43 #[serde(default = "default_line_range")]
44 pub lines: LineRange,
45 pub intent: String,
46 pub reasoning: Option<String>,
47 #[serde(default, deserialize_with = "deserialize_flexible_constraints")]
48 pub constraints: Vec<ConstraintInput>,
49 #[serde(default)]
50 pub semantic_dependencies: Vec<SemanticDependency>,
51 #[serde(default)]
52 pub tags: Vec<String>,
53 pub risk_notes: Option<String>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
59pub struct AnchorInput {
60 pub unit_type: String,
61 pub name: String,
62}
63
64impl RegionInput {
65 pub fn effective_anchor(&self) -> AnchorInput {
67 self.anchor.clone().unwrap_or_else(|| {
68 let name = Path::new(&self.file)
69 .file_name()
70 .and_then(|n| n.to_str())
71 .unwrap_or(&self.file)
72 .to_string();
73 AnchorInput {
74 unit_type: "file".to_string(),
75 name,
76 }
77 })
78 }
79}
80
81#[derive(Debug, Clone, Deserialize)]
85pub struct ConstraintInput {
86 pub text: String,
87}
88
89fn deserialize_flexible_constraints<'de, D>(
93 deserializer: D,
94) -> std::result::Result<Vec<ConstraintInput>, D::Error>
95where
96 D: Deserializer<'de>,
97{
98 struct FlexibleConstraintsVisitor;
99
100 impl<'de> Visitor<'de> for FlexibleConstraintsVisitor {
101 type Value = Vec<ConstraintInput>;
102
103 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
104 formatter.write_str("a list of strings or {\"text\": \"...\"} objects")
105 }
106
107 fn visit_seq<S>(self, mut seq: S) -> std::result::Result<Vec<ConstraintInput>, S::Error>
108 where
109 S: SeqAccess<'de>,
110 {
111 let mut constraints = Vec::new();
112 while let Some(item) = seq.next_element::<FlexibleConstraint>()? {
113 constraints.push(item.into());
114 }
115 Ok(constraints)
116 }
117 }
118
119 deserializer.deserialize_seq(FlexibleConstraintsVisitor)
120}
121
122#[derive(Debug, Clone, Deserialize)]
124#[serde(untagged)]
125enum FlexibleConstraint {
126 Object { text: String },
127 Plain(String),
128}
129
130impl From<FlexibleConstraint> for ConstraintInput {
131 fn from(fc: FlexibleConstraint) -> Self {
132 match fc {
133 FlexibleConstraint::Object { text } => ConstraintInput { text },
134 FlexibleConstraint::Plain(text) => ConstraintInput { text },
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize)]
145pub struct AnnotateResult {
146 pub success: bool,
147 pub commit: String,
148 pub regions_written: usize,
149 pub warnings: Vec<String>,
150 pub anchor_resolutions: Vec<AnchorResolution>,
151}
152
153#[derive(Debug, Clone, Serialize)]
155pub struct AnchorResolution {
156 pub file: String,
157 pub requested_name: String,
158 pub resolution: AnchorResolutionKind,
159}
160
161#[derive(Debug, Clone, Serialize)]
162#[serde(rename_all = "snake_case", tag = "kind")]
163pub enum AnchorResolutionKind {
164 Exact,
165 Qualified {
166 resolved_name: String,
167 },
168 Fuzzy {
169 resolved_name: String,
170 distance: u32,
171 },
172 Unresolved,
173}
174
175fn check_quality(input: &AnnotateInput) -> Vec<String> {
180 let mut warnings = Vec::new();
181
182 if input.summary.len() < 20 {
183 warnings.push("Summary is very short — consider adding more detail".to_string());
184 }
185
186 for (i, region) in input.regions.iter().enumerate() {
187 if region.intent.len() < 10 {
188 let anchor = region.effective_anchor();
189 warnings.push(format!(
190 "region[{}] ({}/{}): intent is very short",
191 i, region.file, anchor.name
192 ));
193 }
194 }
195
196 warnings
197}
198
199pub fn handle_annotate(git_ops: &dyn GitOps, input: AnnotateInput) -> Result<AnnotateResult> {
209 let full_sha = git_ops
211 .resolve_ref(&input.commit)
212 .context(chronicle_error::GitSnafu)?;
213
214 let warnings = check_quality(&input);
216
217 let mut regions = Vec::new();
219 let mut anchor_resolutions = Vec::new();
220
221 for region_input in &input.regions {
222 let (region, resolution) = resolve_and_build_region(git_ops, &full_sha, region_input)?;
223 regions.push(region);
224 anchor_resolutions.push(resolution);
225 }
226
227 let annotation = Annotation {
229 schema: "chronicle/v1".to_string(),
230 commit: full_sha.clone(),
231 timestamp: chrono::Utc::now().to_rfc3339(),
232 task: input.task.clone(),
233 summary: input.summary.clone(),
234 context_level: ContextLevel::Enhanced,
235 regions,
236 cross_cutting: input.cross_cutting.clone(),
237 provenance: Provenance {
238 operation: ProvenanceOperation::Initial,
239 derived_from: Vec::new(),
240 original_annotations_preserved: false,
241 synthesis_notes: None,
242 },
243 };
244
245 annotation
247 .validate()
248 .map_err(|msg| crate::error::ChronicleError::Validation {
249 message: msg,
250 location: snafu::Location::new(file!(), line!(), 0),
251 })?;
252
253 let json = serde_json::to_string_pretty(&annotation).context(chronicle_error::JsonSnafu)?;
255 git_ops
256 .note_write(&full_sha, &json)
257 .context(chronicle_error::GitSnafu)?;
258
259 Ok(AnnotateResult {
260 success: true,
261 commit: full_sha,
262 regions_written: annotation.regions.len(),
263 warnings,
264 anchor_resolutions,
265 })
266}
267
268fn resolve_and_build_region(
271 git_ops: &dyn GitOps,
272 commit: &str,
273 input: &RegionInput,
274) -> Result<(RegionAnnotation, AnchorResolution)> {
275 let file_path = Path::new(&input.file);
276 let lang = Language::from_path(&input.file);
277 let anchor = input.effective_anchor();
278
279 let (ast_anchor, lines, resolution_kind) = match lang {
281 Language::Unsupported => {
282 (
284 AstAnchor {
285 unit_type: anchor.unit_type.clone(),
286 name: anchor.name.clone(),
287 signature: None,
288 },
289 input.lines,
290 AnchorResolutionKind::Unresolved,
291 )
292 }
293 _ => {
294 match git_ops.file_at_commit(file_path, commit) {
295 Ok(source) => {
296 match ast::extract_outline(&source, lang) {
297 Ok(outline) => {
298 match ast::resolve_anchor(&outline, &anchor.unit_type, &anchor.name) {
299 Some(anchor_match) => {
300 let entry = anchor_match.entry();
301 let corrected_lines = anchor_match.lines();
302 let resolution_kind = match &anchor_match {
303 AnchorMatch::Exact(_) => AnchorResolutionKind::Exact,
304 AnchorMatch::Qualified(e) => {
305 AnchorResolutionKind::Qualified {
306 resolved_name: e.name.clone(),
307 }
308 }
309 AnchorMatch::Fuzzy(e, d) => AnchorResolutionKind::Fuzzy {
310 resolved_name: e.name.clone(),
311 distance: *d,
312 },
313 };
314
315 (
316 AstAnchor {
317 unit_type: entry.kind.as_str().to_string(),
318 name: entry.name.clone(),
319 signature: entry.signature.clone(),
320 },
321 corrected_lines,
322 resolution_kind,
323 )
324 }
325 None => {
326 (
328 AstAnchor {
329 unit_type: anchor.unit_type.clone(),
330 name: anchor.name.clone(),
331 signature: None,
332 },
333 input.lines,
334 AnchorResolutionKind::Unresolved,
335 )
336 }
337 }
338 }
339 Err(_) => {
340 (
342 AstAnchor {
343 unit_type: anchor.unit_type.clone(),
344 name: anchor.name.clone(),
345 signature: None,
346 },
347 input.lines,
348 AnchorResolutionKind::Unresolved,
349 )
350 }
351 }
352 }
353 Err(_) => {
354 (
356 AstAnchor {
357 unit_type: anchor.unit_type.clone(),
358 name: anchor.name.clone(),
359 signature: None,
360 },
361 input.lines,
362 AnchorResolutionKind::Unresolved,
363 )
364 }
365 }
366 }
367 };
368
369 let constraints: Vec<Constraint> = input
370 .constraints
371 .iter()
372 .map(|c| Constraint {
373 text: c.text.clone(),
374 source: ConstraintSource::Author,
375 })
376 .collect();
377
378 let region = RegionAnnotation {
379 file: input.file.clone(),
380 ast_anchor,
381 lines,
382 intent: input.intent.clone(),
383 reasoning: input.reasoning.clone(),
384 constraints,
385 semantic_dependencies: input.semantic_dependencies.clone(),
386 related_annotations: Vec::new(),
387 tags: input.tags.clone(),
388 risk_notes: input.risk_notes.clone(),
389 corrections: Vec::new(),
390 };
391
392 let resolution = AnchorResolution {
393 file: input.file.clone(),
394 requested_name: anchor.name.clone(),
395 resolution: resolution_kind,
396 };
397
398 Ok((region, resolution))
399}
400
401#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::error::GitError;
409 use crate::git::diff::FileDiff;
410 use crate::git::CommitInfo;
411 use std::collections::HashMap;
412 use std::sync::Mutex;
413
414 struct MockGitOps {
416 resolved_sha: String,
417 files: HashMap<String, String>,
418 written_notes: Mutex<Vec<(String, String)>>,
419 }
420
421 impl MockGitOps {
422 fn new(sha: &str) -> Self {
423 Self {
424 resolved_sha: sha.to_string(),
425 files: HashMap::new(),
426 written_notes: Mutex::new(Vec::new()),
427 }
428 }
429
430 fn with_file(mut self, path: &str, content: &str) -> Self {
431 self.files.insert(path.to_string(), content.to_string());
432 self
433 }
434
435 fn written_notes(&self) -> Vec<(String, String)> {
436 self.written_notes.lock().unwrap().clone()
437 }
438 }
439
440 impl GitOps for MockGitOps {
441 fn diff(&self, _commit: &str) -> std::result::Result<Vec<FileDiff>, GitError> {
442 Ok(Vec::new())
443 }
444
445 fn note_read(&self, _commit: &str) -> std::result::Result<Option<String>, GitError> {
446 Ok(None)
447 }
448
449 fn note_write(&self, commit: &str, content: &str) -> std::result::Result<(), GitError> {
450 self.written_notes
451 .lock()
452 .unwrap()
453 .push((commit.to_string(), content.to_string()));
454 Ok(())
455 }
456
457 fn note_exists(&self, _commit: &str) -> std::result::Result<bool, GitError> {
458 Ok(false)
459 }
460
461 fn file_at_commit(
462 &self,
463 path: &Path,
464 _commit: &str,
465 ) -> std::result::Result<String, GitError> {
466 self.files
467 .get(path.to_str().unwrap_or(""))
468 .cloned()
469 .ok_or(GitError::FileNotFound {
470 path: path.display().to_string(),
471 commit: "test".to_string(),
472 location: snafu::Location::new(file!(), line!(), 0),
473 })
474 }
475
476 fn commit_info(&self, _commit: &str) -> std::result::Result<CommitInfo, GitError> {
477 Ok(CommitInfo {
478 sha: self.resolved_sha.clone(),
479 message: "test commit".to_string(),
480 author_name: "Test".to_string(),
481 author_email: "test@test.com".to_string(),
482 timestamp: "2024-01-01T00:00:00Z".to_string(),
483 parent_shas: Vec::new(),
484 })
485 }
486
487 fn resolve_ref(&self, _refspec: &str) -> std::result::Result<String, GitError> {
488 Ok(self.resolved_sha.clone())
489 }
490
491 fn config_get(&self, _key: &str) -> std::result::Result<Option<String>, GitError> {
492 Ok(None)
493 }
494
495 fn config_set(&self, _key: &str, _value: &str) -> std::result::Result<(), GitError> {
496 Ok(())
497 }
498
499 fn log_for_file(&self, _path: &str) -> std::result::Result<Vec<String>, GitError> {
500 Ok(vec![])
501 }
502
503 fn list_annotated_commits(
504 &self,
505 _limit: u32,
506 ) -> std::result::Result<Vec<String>, GitError> {
507 Ok(vec![])
508 }
509 }
510
511 fn sample_rust_source() -> &'static str {
512 r#"
513pub fn hello_world() {
514 println!("Hello, world!");
515}
516
517pub struct Config {
518 pub name: String,
519}
520
521impl Config {
522 pub fn new(name: String) -> Self {
523 Self { name }
524 }
525}
526"#
527 }
528
529 fn make_basic_input() -> AnnotateInput {
530 AnnotateInput {
531 commit: "HEAD".to_string(),
532 summary: "Add hello_world function and Config struct".to_string(),
533 task: Some("TASK-123".to_string()),
534 regions: vec![RegionInput {
535 file: "src/lib.rs".to_string(),
536 anchor: Some(AnchorInput {
537 unit_type: "function".to_string(),
538 name: "hello_world".to_string(),
539 }),
540 lines: LineRange { start: 2, end: 4 },
541 intent: "Add a greeting function for the CLI entrypoint".to_string(),
542 reasoning: Some("Needed a simple entry point for testing".to_string()),
543 constraints: vec![ConstraintInput {
544 text: "Must print to stdout, not stderr".to_string(),
545 }],
546 semantic_dependencies: vec![],
547 tags: vec!["cli".to_string()],
548 risk_notes: None,
549 }],
550 cross_cutting: vec![],
551 }
552 }
553
554 #[test]
555 fn test_handle_annotate_writes_note() {
556 let mock = MockGitOps::new("abc123def456").with_file("src/lib.rs", sample_rust_source());
557
558 let input = make_basic_input();
559 let result = handle_annotate(&mock, input).unwrap();
560
561 assert!(result.success);
562 assert_eq!(result.commit, "abc123def456");
563 assert_eq!(result.regions_written, 1);
564
565 let notes = mock.written_notes();
567 assert_eq!(notes.len(), 1);
568 assert_eq!(notes[0].0, "abc123def456");
569
570 let annotation: Annotation = serde_json::from_str(¬es[0].1).unwrap();
572 assert_eq!(annotation.schema, "chronicle/v1");
573 assert_eq!(annotation.commit, "abc123def456");
574 assert_eq!(annotation.context_level, ContextLevel::Enhanced);
575 assert_eq!(annotation.task, Some("TASK-123".to_string()));
576 }
577
578 #[test]
579 fn test_anchor_resolution_exact() {
580 let mock = MockGitOps::new("abc123").with_file("src/lib.rs", sample_rust_source());
581
582 let input = make_basic_input();
583 let result = handle_annotate(&mock, input).unwrap();
584
585 assert!(!result.anchor_resolutions.is_empty());
587
588 assert!(matches!(
590 result.anchor_resolutions[0].resolution,
591 AnchorResolutionKind::Exact
592 ));
593 }
594
595 #[test]
596 fn test_anchor_resolution_corrects_lines() {
597 let mock = MockGitOps::new("abc123").with_file("src/lib.rs", sample_rust_source());
598
599 let input = make_basic_input();
600 let _result = handle_annotate(&mock, input).unwrap();
601
602 let notes = mock.written_notes();
604 let annotation: Annotation = serde_json::from_str(¬es[0].1).unwrap();
605
606 let region = &annotation.regions[0];
608 assert!(region.lines.start > 0);
609 assert!(region.lines.end >= region.lines.start);
610 assert!(region.ast_anchor.signature.is_some());
612 }
613
614 #[test]
615 fn test_constraints_have_author_source() {
616 let mock = MockGitOps::new("abc123").with_file("src/lib.rs", sample_rust_source());
617
618 let input = make_basic_input();
619 handle_annotate(&mock, input).unwrap();
620
621 let notes = mock.written_notes();
622 let annotation: Annotation = serde_json::from_str(¬es[0].1).unwrap();
623
624 for constraint in &annotation.regions[0].constraints {
625 assert_eq!(constraint.source, ConstraintSource::Author);
626 }
627 }
628
629 #[test]
630 fn test_quality_warnings() {
631 let input = AnnotateInput {
632 commit: "HEAD".to_string(),
633 summary: "short".to_string(), task: None,
635 regions: vec![RegionInput {
636 file: "src/lib.rs".to_string(),
637 anchor: Some(AnchorInput {
638 unit_type: "function".to_string(),
639 name: "foo".to_string(),
640 }),
641 lines: LineRange { start: 1, end: 5 },
642 intent: "short".to_string(), reasoning: None, constraints: vec![], semantic_dependencies: vec![],
646 tags: vec![],
647 risk_notes: None,
648 }],
649 cross_cutting: vec![],
650 };
651
652 let warnings = check_quality(&input);
653 assert!(warnings.iter().any(|w| w.contains("Summary is very short")));
654 assert!(warnings.iter().any(|w| w.contains("intent is very short")));
655 assert!(!warnings.iter().any(|w| w.contains("no reasoning")));
657 assert!(!warnings.iter().any(|w| w.contains("no constraints")));
658 }
659
660 #[test]
661 fn test_serde_defaults_for_optional_vec_fields() {
662 let json = r#"{
664 "commit": "HEAD",
665 "summary": "Test summary for serde defaults",
666 "regions": [{
667 "file": "src/lib.rs",
668 "anchor": { "unit_type": "function", "name": "foo" },
669 "lines": { "start": 1, "end": 5 },
670 "intent": "Test intent for serde defaults"
671 }]
672 }"#;
673
674 let input: AnnotateInput = serde_json::from_str(json).unwrap();
675 assert!(input.cross_cutting.is_empty());
676 assert_eq!(input.regions.len(), 1);
677 assert!(input.regions[0].constraints.is_empty());
678 assert!(input.regions[0].semantic_dependencies.is_empty());
679 assert!(input.regions[0].tags.is_empty());
680 }
681
682 #[test]
683 fn test_validation_rejects_empty_summary() {
684 let mock = MockGitOps::new("abc123").with_file("src/lib.rs", sample_rust_source());
685
686 let input = AnnotateInput {
687 commit: "HEAD".to_string(),
688 summary: "".to_string(),
689 task: None,
690 regions: vec![],
691 cross_cutting: vec![],
692 };
693
694 let result = handle_annotate(&mock, input);
695 assert!(result.is_err());
696 }
697
698 #[test]
699 fn test_unsupported_language_uses_input_as_is() {
700 let mock =
701 MockGitOps::new("abc123").with_file("src/data.toml", "[section]\nkey = \"value\"\n");
702
703 let input = AnnotateInput {
704 commit: "HEAD".to_string(),
705 summary: "Add TOML config data".to_string(),
706 task: None,
707 regions: vec![RegionInput {
708 file: "src/data.toml".to_string(),
709 anchor: Some(AnchorInput {
710 unit_type: "function".to_string(),
711 name: "section".to_string(),
712 }),
713 lines: LineRange { start: 1, end: 2 },
714 intent: "Add a config section".to_string(),
715 reasoning: None,
716 constraints: vec![],
717 semantic_dependencies: vec![],
718 tags: vec![],
719 risk_notes: None,
720 }],
721 cross_cutting: vec![],
722 };
723
724 let result = handle_annotate(&mock, input).unwrap();
725 assert!(result.success);
726 assert!(matches!(
727 result.anchor_resolutions[0].resolution,
728 AnchorResolutionKind::Unresolved
729 ));
730 }
731
732 #[test]
733 fn test_file_not_at_commit_uses_input_as_is() {
734 let mock = MockGitOps::new("abc123");
736
737 let input = AnnotateInput {
738 commit: "HEAD".to_string(),
739 summary: "Update something in a file".to_string(),
740 task: None,
741 regions: vec![RegionInput {
742 file: "src/missing.rs".to_string(),
743 anchor: Some(AnchorInput {
744 unit_type: "function".to_string(),
745 name: "missing_fn".to_string(),
746 }),
747 lines: LineRange { start: 1, end: 10 },
748 intent: "Modify a function that was deleted".to_string(),
749 reasoning: None,
750 constraints: vec![],
751 semantic_dependencies: vec![],
752 tags: vec![],
753 risk_notes: None,
754 }],
755 cross_cutting: vec![],
756 };
757
758 let result = handle_annotate(&mock, input).unwrap();
759 assert!(result.success);
760 assert!(matches!(
761 result.anchor_resolutions[0].resolution,
762 AnchorResolutionKind::Unresolved
763 ));
764 }
765
766 #[test]
767 fn test_omitted_anchor_defaults_to_filename() {
768 let json = r#"{
769 "commit": "HEAD",
770 "summary": "Update config file with new settings",
771 "regions": [{
772 "file": "config/settings.toml",
773 "intent": "Add database connection pool settings"
774 }]
775 }"#;
776
777 let input: AnnotateInput = serde_json::from_str(json).unwrap();
778 assert!(input.regions[0].anchor.is_none());
779
780 let anchor = input.regions[0].effective_anchor();
781 assert_eq!(anchor.unit_type, "file");
782 assert_eq!(anchor.name, "settings.toml");
783 }
784
785 #[test]
786 fn test_null_anchor_defaults_to_filename() {
787 let json = r#"{
788 "commit": "HEAD",
789 "summary": "Update config file with new settings",
790 "regions": [{
791 "file": ".github/workflows/ci.yml",
792 "anchor": null,
793 "intent": "Add CI workflow for pull requests"
794 }]
795 }"#;
796
797 let input: AnnotateInput = serde_json::from_str(json).unwrap();
798 assert!(input.regions[0].anchor.is_none());
799
800 let anchor = input.regions[0].effective_anchor();
801 assert_eq!(anchor.unit_type, "file");
802 assert_eq!(anchor.name, "ci.yml");
803 }
804
805 #[test]
806 fn test_path_alias_for_file_field() {
807 let json = r#"{
808 "commit": "HEAD",
809 "summary": "Test path alias for the file field",
810 "regions": [{
811 "path": "src/main.rs",
812 "anchor": { "unit_type": "function", "name": "main" },
813 "intent": "Test that path works as an alias for file"
814 }]
815 }"#;
816
817 let input: AnnotateInput = serde_json::from_str(json).unwrap();
818 assert_eq!(input.regions[0].file, "src/main.rs");
819 }
820
821 #[test]
822 fn test_constraints_as_plain_strings() {
823 let json = r#"{
824 "commit": "HEAD",
825 "summary": "Test plain string constraints",
826 "regions": [{
827 "file": "src/lib.rs",
828 "anchor": { "unit_type": "function", "name": "foo" },
829 "intent": "Test that plain string constraints are accepted",
830 "constraints": ["Must not allocate", "Assumes sorted input"]
831 }]
832 }"#;
833
834 let input: AnnotateInput = serde_json::from_str(json).unwrap();
835 assert_eq!(input.regions[0].constraints.len(), 2);
836 assert_eq!(input.regions[0].constraints[0].text, "Must not allocate");
837 assert_eq!(input.regions[0].constraints[1].text, "Assumes sorted input");
838 }
839
840 #[test]
841 fn test_constraints_as_objects() {
842 let json = r#"{
843 "commit": "HEAD",
844 "summary": "Test object constraints still work",
845 "regions": [{
846 "file": "src/lib.rs",
847 "anchor": { "unit_type": "function", "name": "foo" },
848 "intent": "Test that object constraints are still accepted",
849 "constraints": [{"text": "Must not allocate"}, {"text": "Assumes sorted input"}]
850 }]
851 }"#;
852
853 let input: AnnotateInput = serde_json::from_str(json).unwrap();
854 assert_eq!(input.regions[0].constraints.len(), 2);
855 assert_eq!(input.regions[0].constraints[0].text, "Must not allocate");
856 assert_eq!(input.regions[0].constraints[1].text, "Assumes sorted input");
857 }
858
859 #[test]
860 fn test_constraints_mixed_strings_and_objects() {
861 let json = r#"{
862 "commit": "HEAD",
863 "summary": "Test mixed constraint formats",
864 "regions": [{
865 "file": "src/lib.rs",
866 "anchor": { "unit_type": "function", "name": "foo" },
867 "intent": "Test that mixed constraint formats are accepted",
868 "constraints": ["Plain string", {"text": "Object form"}]
869 }]
870 }"#;
871
872 let input: AnnotateInput = serde_json::from_str(json).unwrap();
873 assert_eq!(input.regions[0].constraints.len(), 2);
874 assert_eq!(input.regions[0].constraints[0].text, "Plain string");
875 assert_eq!(input.regions[0].constraints[1].text, "Object form");
876 }
877}