Skip to main content

chronicle/mcp/
annotate_handler.rs

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// ---------------------------------------------------------------------------
16// Input types (from the calling agent)
17// ---------------------------------------------------------------------------
18
19/// Input provided by the calling agent when annotating a commit.
20#[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
30/// Default line range used when the caller omits `lines`.
31/// AST anchor resolution will correct this to the actual range.
32fn default_line_range() -> LineRange {
33    LineRange { start: 0, end: 0 }
34}
35
36/// A single region the agent wants to annotate.
37#[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/// Simplified anchor — the agent provides unit_type and name;
57/// the handler resolves the full signature and corrected lines via AST.
58#[derive(Debug, Clone, Deserialize)]
59pub struct AnchorInput {
60    pub unit_type: String,
61    pub name: String,
62}
63
64impl RegionInput {
65    /// Returns the anchor, defaulting to a file-level anchor derived from the filename.
66    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/// A constraint supplied by the author (source is always `Author`).
82///
83/// Accepts either a plain string `"text"` or an object `{"text": "..."}`.
84#[derive(Debug, Clone, Deserialize)]
85pub struct ConstraintInput {
86    pub text: String,
87}
88
89/// Deserializes a `Vec<ConstraintInput>` where each element can be either:
90/// - a plain string: `"Must not allocate"` → `ConstraintInput { text: "Must not allocate" }`
91/// - an object: `{"text": "Must not allocate"}` → `ConstraintInput { text: "Must not allocate" }`
92fn 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/// Intermediate type that accepts either a string or a `{"text": "..."}` object.
123#[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// ---------------------------------------------------------------------------
140// Output types
141// ---------------------------------------------------------------------------
142
143/// Result returned after writing the annotation.
144#[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/// How an anchor was resolved (or not) during annotation.
154#[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
175// ---------------------------------------------------------------------------
176// Quality checks (non-blocking warnings)
177// ---------------------------------------------------------------------------
178
179fn 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
199// ---------------------------------------------------------------------------
200// Handler
201// ---------------------------------------------------------------------------
202
203/// Core handler: validates input, resolves anchors via AST, builds and writes
204/// the annotation as a git note.
205///
206/// This is the "live path" — called by the agent directly after committing,
207/// with zero LLM cost.
208pub fn handle_annotate(git_ops: &dyn GitOps, input: AnnotateInput) -> Result<AnnotateResult> {
209    // 1. Resolve commit ref to full SHA
210    let full_sha = git_ops
211        .resolve_ref(&input.commit)
212        .context(chronicle_error::GitSnafu)?;
213
214    // 2. Quality warnings (non-blocking)
215    let warnings = check_quality(&input);
216
217    // 3. Resolve anchors and build regions
218    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    // 4. Build annotation
228    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    // 5. Validate (reject on structural errors)
246    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    // 6. Serialize and write git note
254    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
268/// Resolve a single region's anchor against the AST outline and build the
269/// final `RegionAnnotation`.
270fn 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    // Try to load the file and resolve the anchor via AST
280    let (ast_anchor, lines, resolution_kind) = match lang {
281        Language::Unsupported => {
282            // No AST support — use the input as-is
283            (
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                                    // No match — use input as-is
327                                    (
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                            // Outline extraction failed — use input as-is
341                            (
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                    // File not available at commit — use input as-is
355                    (
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// ---------------------------------------------------------------------------
402// Tests
403// ---------------------------------------------------------------------------
404
405#[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    /// A mock GitOps for testing the annotate handler.
415    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        // Verify a note was written
566        let notes = mock.written_notes();
567        assert_eq!(notes.len(), 1);
568        assert_eq!(notes[0].0, "abc123def456");
569
570        // Verify the note is valid JSON with the expected schema
571        let annotation: Annotation = serde_json::from_str(&notes[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        // Verify the anchor was resolved
586        assert!(!result.anchor_resolutions.is_empty());
587
588        // hello_world should resolve exactly
589        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        // Verify the note was written
603        let notes = mock.written_notes();
604        let annotation: Annotation = serde_json::from_str(&notes[0].1).unwrap();
605
606        // The AST should correct the line range to the actual function location
607        let region = &annotation.regions[0];
608        assert!(region.lines.start > 0);
609        assert!(region.lines.end >= region.lines.start);
610        // Signature should be filled in by AST
611        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(&notes[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(), // too short
634            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(), // too short
643                reasoning: None,             // missing
644                constraints: vec![],         // missing
645                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        // reasoning and constraints are genuinely optional — no warnings for them
656        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        // Minimal JSON omitting constraints, semantic_dependencies, tags, and cross_cutting
663        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        // No files registered in mock — file_at_commit will fail
735        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}