Skip to main content

semver_analyzer_llm/
invoke.rs

1//! LLM command invocation and response parsing.
2//!
3//! Handles running external LLM commands and extracting structured JSON
4//! from their free-text output. Uses multiple strategies for JSON extraction
5//! to handle different LLM output formats.
6
7use anyhow::{Context, Result};
8use regex::Regex;
9use semver_analyzer_core::{BreakingVerdict, ExpectedChild, FunctionSpec, RemovalDisposition};
10use serde::Deserialize;
11use std::process::Command;
12use std::sync::LazyLock;
13
14/// Run an LLM command with the given prompt and return the output.
15///
16/// The command string is split on whitespace and the prompt is appended
17/// as the final argument. The command is expected to return a response
18/// on stdout.
19///
20/// Examples:
21/// - `"goose run --no-session -q -t"` → `goose run --no-session -q -t "<prompt>"`
22/// - `"opencode run"` → `opencode run "<prompt>"`
23pub fn run_llm_command(command: &str, prompt: &str, timeout_secs: u64) -> Result<String> {
24    let parts: Vec<&str> = command.split_whitespace().collect();
25    if parts.is_empty() {
26        anyhow::bail!("Empty LLM command");
27    }
28
29    let program = parts[0];
30    let args = &parts[1..];
31
32    let mut child = Command::new(program)
33        .args(args)
34        .arg(prompt)
35        .stdout(std::process::Stdio::piped())
36        .stderr(std::process::Stdio::piped())
37        .spawn()
38        .with_context(|| format!("Failed to execute LLM command: {}", command))?;
39
40    // Wait with timeout
41    let timeout = std::time::Duration::from_secs(timeout_secs);
42    let start = std::time::Instant::now();
43
44    loop {
45        match child.try_wait() {
46            Ok(Some(status)) => {
47                // Process finished
48                let output = child.wait_with_output()?;
49                if !status.success() {
50                    let stderr = String::from_utf8_lossy(&output.stderr);
51                    anyhow::bail!(
52                        "LLM command failed (exit code {:?}): {}",
53                        status.code(),
54                        stderr
55                    );
56                }
57                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
58                if stdout.trim().is_empty() {
59                    anyhow::bail!("LLM command returned empty output");
60                }
61                // Goose truncates large stdout and writes the full output
62                // to a temp file, e.g.:
63                //   ... (57 more lines → /tmp/goose-xxx.txt)
64                // Detect this and read the temp file to get the full response.
65                return Ok(resolve_goose_overflow(&stdout));
66            }
67            Ok(None) => {
68                // Still running
69                if start.elapsed() > timeout {
70                    let _ = child.kill();
71                    anyhow::bail!("LLM command timed out after {} seconds", timeout_secs);
72                }
73                std::thread::sleep(std::time::Duration::from_millis(100));
74            }
75            Err(e) => {
76                anyhow::bail!("Error waiting for LLM command: {}", e);
77            }
78        }
79    }
80}
81
82/// Goose CLI truncates large outputs to stdout and saves the full text to a
83/// temp file. The truncation marker looks like:
84///   `... (57 more lines → /var/folders/.../goose-XxYz.txt)`
85/// This function detects the marker, reads the overflow file, and reconstructs
86/// the full response by replacing the truncation line with the file contents.
87fn resolve_goose_overflow(stdout: &str) -> String {
88    // Pattern: "... (N more lines → <path>)"
89    // The marker is always on a line by itself, near the end of stdout.
90    static OVERFLOW_RE: LazyLock<Regex> =
91        LazyLock::new(|| Regex::new(r"(?m)^\.\.\. \(\d+ more lines → (.+)\)\s*$").unwrap());
92
93    let Some(caps) = OVERFLOW_RE.captures(stdout) else {
94        return stdout.to_string();
95    };
96
97    let overflow_path = caps.get(1).unwrap().as_str();
98    let overflow_content = match std::fs::read_to_string(overflow_path) {
99        Ok(content) => content,
100        Err(e) => {
101            tracing::warn!(
102                path = overflow_path,
103                %e,
104                "failed to read goose overflow file, using truncated stdout"
105            );
106            return stdout.to_string();
107        }
108    };
109
110    // The overflow file contains the FULL response (without code fences).
111    // Use it directly since it's complete.
112    tracing::debug!(
113        path = overflow_path,
114        overflow_lines = overflow_content.lines().count(),
115        "resolved goose overflow file"
116    );
117    overflow_content
118}
119
120/// Parse a `FunctionSpec` from LLM output.
121///
122/// Tries multiple strategies:
123/// 1. Fenced JSON block (```json ... ```)
124/// 2. Raw JSON object ({ ... })
125/// 3. JSON embedded in prose text
126pub fn parse_function_spec(response: &str) -> Result<FunctionSpec> {
127    let json_str = extract_json(response).context("Could not extract JSON from LLM response")?;
128
129    serde_json::from_str(&json_str).with_context(|| {
130        format!(
131            "Failed to parse FunctionSpec from JSON. Extracted:\n{}",
132            truncate(&json_str, 500)
133        )
134    })
135}
136
137/// Parse a `BreakingVerdict` from LLM output.
138pub fn parse_breaking_verdict(response: &str) -> Result<BreakingVerdict> {
139    let json_str =
140        extract_json(response).context("Could not extract JSON from LLM response for verdict")?;
141
142    serde_json::from_str(&json_str).with_context(|| {
143        format!(
144            "Failed to parse BreakingVerdict from JSON. Extracted:\n{}",
145            truncate(&json_str, 500)
146        )
147    })
148}
149
150/// Parse a boolean propagation result from LLM output.
151///
152/// Looks for clear yes/no signals. Defaults to `true` (conservative:
153/// assume propagation) if the response is ambiguous.
154pub fn parse_propagation_result(response: &str) -> Result<bool> {
155    let lower = response.to_lowercase();
156
157    // Try to parse as JSON first
158    if let Some(json_str) = extract_json(response) {
159        if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json_str) {
160            if let Some(propagates) = val.get("propagates").and_then(|v| v.as_bool()) {
161                return Ok(propagates);
162            }
163            if let Some(propagates) = val.get("is_breaking").and_then(|v| v.as_bool()) {
164                return Ok(propagates);
165            }
166        }
167    }
168
169    // Heuristic text matching
170    if lower.contains("does not propagate")
171        || lower.contains("does not affect")
172        || lower.contains("absorbs the change")
173        || lower.contains("masks the change")
174        || lower.contains("no propagation")
175    {
176        return Ok(false);
177    }
178
179    if lower.contains("propagates")
180        || lower.contains("is affected")
181        || lower.contains("breaks the caller")
182        || lower.contains("yes, the change propagates")
183    {
184        return Ok(true);
185    }
186
187    // Conservative default: assume propagation
188    Ok(true)
189}
190
191// ── File-level behavioral change parsing ────────────────────────────────
192
193/// A single behavioral change from the file-level LLM response.
194#[derive(Debug, Clone, Deserialize)]
195pub struct FileBehavioralChange {
196    pub symbol: String,
197    #[serde(default = "default_kind")]
198    pub kind: String,
199    /// Sub-category of the behavioral change (language-specific, injected
200    /// via `LlmCategoryDefinition` in the prompt).
201    #[serde(default)]
202    pub category: Option<String>,
203    pub description: String,
204    /// Whether this change only affects internal rendering and does NOT
205    /// require consumer code changes.
206    #[serde(default)]
207    pub is_internal_only: Option<bool>,
208}
209
210fn default_kind() -> String {
211    "class".to_string()
212}
213
214/// A single API type-level change from the file-level LLM response.
215#[derive(Debug, Clone, Deserialize)]
216pub struct FileApiChange {
217    pub symbol: String,
218    #[serde(default = "default_change")]
219    pub change: String,
220    pub description: String,
221    /// Why a removed prop was removed and where its functionality went.
222    #[serde(default)]
223    pub removal_disposition: Option<RemovalDisposition>,
224}
225
226fn default_change() -> String {
227    "signature_changed".to_string()
228}
229
230/// Parsed response from the file-level analysis prompt.
231#[derive(Debug, Clone, Deserialize)]
232pub struct FileBehavioralResponse {
233    #[serde(default)]
234    pub breaking_behavioral_changes: Vec<FileBehavioralChange>,
235    #[serde(default)]
236    pub breaking_api_changes: Vec<FileApiChange>,
237}
238
239/// Parse file-level changes (behavioral + API) from LLM output.
240pub fn parse_file_behavioral_response(
241    response: &str,
242) -> Result<(Vec<FileBehavioralChange>, Vec<FileApiChange>)> {
243    let json_str = extract_json(response)
244        .context("Could not extract JSON from LLM response for file analysis")?;
245
246    let parsed: FileBehavioralResponse = serde_json::from_str(&json_str).with_context(|| {
247        format!(
248            "Failed to parse FileBehavioralResponse from JSON. Extracted:\n{}",
249            truncate(&json_str, 500)
250        )
251    })?;
252
253    Ok((
254        parsed.breaking_behavioral_changes,
255        parsed.breaking_api_changes,
256    ))
257}
258
259// ── JSON Extraction ─────────────────────────────────────────────────────
260
261/// Regex for fenced JSON blocks.
262static FENCED_JSON_RE: LazyLock<Regex> =
263    LazyLock::new(|| Regex::new(r"```(?:json)?\s*\n([\s\S]*?)\n```").unwrap());
264
265/// Regex for standalone JSON objects.
266static JSON_OBJECT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[\s\S]*\}").unwrap());
267
268/// Extract JSON from LLM output using multiple strategies.
269///
270/// Strategy order (first match wins):
271/// 1. Fenced JSON block: ```json\n{...}\n```
272/// 2. Last fenced block (if multiple)
273/// 3. Largest `{...}` substring
274fn extract_json(text: &str) -> Option<String> {
275    // Strategy 1: Fenced JSON block
276    let fenced_matches: Vec<_> = FENCED_JSON_RE
277        .captures_iter(text)
278        .filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string()))
279        .collect();
280
281    if let Some(last) = fenced_matches.last() {
282        return Some(last.clone());
283    }
284
285    // Strategy 2: Find the largest JSON object in the text
286    let mut best: Option<String> = None;
287    let mut best_len = 0;
288
289    for mat in JSON_OBJECT_RE.find_iter(text) {
290        let candidate = mat.as_str();
291        // Validate it's actually parseable JSON
292        if serde_json::from_str::<serde_json::Value>(candidate).is_ok()
293            && candidate.len() > best_len
294        {
295            best = Some(candidate.to_string());
296            best_len = candidate.len();
297        }
298    }
299
300    if best.is_some() {
301        return best;
302    }
303
304    // Strategy 3: Try to find a JSON object by brace matching
305    if let Some(start) = text.find('{') {
306        let mut depth = 0;
307        let mut in_string = false;
308        let mut escape = false;
309
310        for (i, ch) in text[start..].char_indices() {
311            if escape {
312                escape = false;
313                continue;
314            }
315            match ch {
316                '\\' if in_string => escape = true,
317                '"' => in_string = !in_string,
318                '{' if !in_string => depth += 1,
319                '}' if !in_string => {
320                    depth -= 1;
321                    if depth == 0 {
322                        let json_str = &text[start..start + i + 1];
323                        if serde_json::from_str::<serde_json::Value>(json_str).is_ok() {
324                            return Some(json_str.to_string());
325                        }
326                    }
327                }
328                _ => {}
329            }
330        }
331    }
332
333    None
334}
335
336/// Truncate a string for error messages.
337fn truncate(s: &str, max_len: usize) -> &str {
338    if s.len() <= max_len {
339        s
340    } else {
341        &s[..max_len]
342    }
343}
344
345// ── Rename inference response parsing ─────────────────────────────────
346
347/// A single constant rename pattern from the LLM response.
348#[derive(Debug, Clone, Deserialize)]
349pub struct LlmConstantRenamePattern {
350    #[serde(alias = "match")]
351    pub match_regex: String,
352    pub replace: String,
353}
354
355/// A single interface rename mapping from the LLM response.
356#[derive(Debug, Clone, Deserialize)]
357pub struct LlmInterfaceRenameMapping {
358    pub old_name: String,
359    pub new_name: String,
360    #[serde(default = "default_confidence")]
361    pub confidence: String,
362    #[serde(default)]
363    pub reason: String,
364}
365
366fn default_confidence() -> String {
367    "medium".to_string()
368}
369
370/// Parse the LLM response for constant rename pattern inference.
371/// Returns a list of regex match/replace patterns.
372pub fn parse_constant_rename_response(response: &str) -> Result<Vec<LlmConstantRenamePattern>> {
373    let json_str = extract_json(response)
374        .with_context(|| "No JSON found in constant rename inference response")?;
375    let patterns: Vec<LlmConstantRenamePattern> =
376        serde_json::from_str(&json_str).with_context(|| {
377            format!(
378                "Failed to parse constant rename patterns: {}",
379                truncate(&json_str, 200)
380            )
381        })?;
382    Ok(patterns)
383}
384
385/// Parse the LLM response for interface rename mapping inference.
386/// Returns a list of old_name → new_name mappings.
387pub fn parse_interface_rename_response(response: &str) -> Result<Vec<LlmInterfaceRenameMapping>> {
388    let json_str = extract_json(response)
389        .with_context(|| "No JSON found in interface rename inference response")?;
390    let mappings: Vec<LlmInterfaceRenameMapping> =
391        serde_json::from_str(&json_str).with_context(|| {
392            format!(
393                "Failed to parse interface rename mappings: {}",
394                truncate(&json_str, 200)
395            )
396        })?;
397    Ok(mappings)
398}
399
400// ── Hierarchy inference response parsing ─────────────────────────────
401
402/// The top-level LLM hierarchy response.
403#[derive(Debug, Clone, Deserialize)]
404pub struct LlmHierarchyResponse {
405    pub components: std::collections::HashMap<String, LlmComponentHierarchy>,
406}
407
408/// A single component's hierarchy entry from the LLM.
409#[derive(Debug, Clone, Deserialize)]
410pub struct LlmComponentHierarchy {
411    #[serde(default)]
412    pub expected_children: Vec<ExpectedChild>,
413}
414
415// ── CSS Suffix Rename Response Parsing ───────────────────────────────────
416
417/// A single suffix rename from the LLM response.
418#[derive(Debug, Clone, Deserialize)]
419pub struct LlmSuffixRename {
420    pub from: String,
421    pub to: String,
422}
423
424/// The top-level LLM suffix rename response.
425#[derive(Debug, Clone, Deserialize)]
426struct LlmSuffixRenameResponse {
427    pub renames: Vec<LlmSuffixRename>,
428}
429
430/// Parse the LLM response for CSS suffix rename inference.
431/// Returns a list of (old_suffix, new_suffix) pairs.
432pub fn parse_suffix_rename_response(response: &str) -> Result<Vec<LlmSuffixRename>> {
433    let json_str = extract_json(response)
434        .with_context(|| "No JSON found in suffix rename inference response")?;
435    let parsed: LlmSuffixRenameResponse = serde_json::from_str(&json_str).with_context(|| {
436        format!(
437            "Failed to parse suffix rename response: {}",
438            truncate(&json_str, 300)
439        )
440    })?;
441    Ok(parsed.renames)
442}
443
444/// Parse the LLM response for hierarchy inference.
445/// Returns a map of component name → expected children.
446pub fn parse_hierarchy_response(
447    response: &str,
448) -> Result<std::collections::HashMap<String, Vec<ExpectedChild>>> {
449    let json_str =
450        extract_json(response).with_context(|| "No JSON found in hierarchy inference response")?;
451    let parsed: LlmHierarchyResponse = serde_json::from_str(&json_str).with_context(|| {
452        format!(
453            "Failed to parse hierarchy response: {}",
454            truncate(&json_str, 300)
455        )
456    })?;
457    Ok(parsed
458        .components
459        .into_iter()
460        .map(|(name, h)| (name, h.expected_children))
461        .collect())
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    // ── JSON extraction tests ───────────────────────────────────────
469
470    #[test]
471    fn extract_fenced_json() {
472        let input = r#"Here is the spec:
473
474```json
475{
476  "preconditions": [],
477  "postconditions": [{"condition": "always", "returns": "42"}],
478  "error_behavior": [],
479  "side_effects": [],
480  "notes": []
481}
482```
483
484That's the spec."#;
485
486        let json = extract_json(input).unwrap();
487        let spec: FunctionSpec = serde_json::from_str(&json).unwrap();
488        assert_eq!(spec.postconditions.len(), 1);
489        assert_eq!(spec.postconditions[0].returns, "42");
490    }
491
492    #[test]
493    fn extract_raw_json() {
494        let input = r#"The function spec is: {"preconditions": [], "postconditions": [], "error_behavior": [], "side_effects": [], "notes": ["simple function"]}"#;
495
496        let json = extract_json(input).unwrap();
497        let spec: FunctionSpec = serde_json::from_str(&json).unwrap();
498        assert_eq!(spec.notes.len(), 1);
499    }
500
501    #[test]
502    fn extract_json_with_prose() {
503        let input = r#"After analyzing the function, I found:
504
505{
506  "preconditions": [
507    {"parameter": "email", "condition": "must be non-empty", "on_violation": "throws TypeError"}
508  ],
509  "postconditions": [],
510  "error_behavior": [],
511  "side_effects": [],
512  "notes": []
513}
514
515The function validates email addresses."#;
516
517        let json = extract_json(input).unwrap();
518        let spec: FunctionSpec = serde_json::from_str(&json).unwrap();
519        assert_eq!(spec.preconditions.len(), 1);
520        assert_eq!(spec.preconditions[0].parameter, "email");
521    }
522
523    #[test]
524    fn extract_json_prefers_fenced() {
525        let input = r#"Small json: {"notes": ["wrong"]}
526
527```json
528{"preconditions": [], "postconditions": [], "error_behavior": [], "side_effects": [], "notes": ["correct"]}
529```"#;
530
531        let json = extract_json(input).unwrap();
532        let spec: FunctionSpec = serde_json::from_str(&json).unwrap();
533        assert_eq!(spec.notes, vec!["correct"]);
534    }
535
536    #[test]
537    fn extract_json_returns_none_for_no_json() {
538        assert!(extract_json("No JSON here at all").is_none());
539        assert!(extract_json("").is_none());
540    }
541
542    // ── FunctionSpec parsing tests ──────────────────────────────────
543
544    #[test]
545    fn parse_spec_from_fenced_block() {
546        let response = r#"```json
547{
548  "preconditions": [],
549  "postconditions": [{"condition": "valid input", "returns": "processed string"}],
550  "error_behavior": [{"trigger": "empty input", "error_type": "Error"}],
551  "side_effects": [],
552  "notes": []
553}
554```"#;
555
556        let spec = parse_function_spec(response).unwrap();
557        assert_eq!(spec.postconditions.len(), 1);
558        assert_eq!(spec.error_behavior.len(), 1);
559    }
560
561    // ── Propagation result parsing ──────────────────────────────────
562
563    #[test]
564    fn parse_propagation_json() {
565        let response = r#"{"propagates": false}"#;
566        assert!(!parse_propagation_result(response).unwrap());
567
568        let response = r#"{"propagates": true}"#;
569        assert!(parse_propagation_result(response).unwrap());
570    }
571
572    #[test]
573    fn parse_propagation_text() {
574        assert!(!parse_propagation_result("The caller does not propagate the change").unwrap());
575        assert!(!parse_propagation_result("It absorbs the change").unwrap());
576        assert!(parse_propagation_result("The change propagates to the caller").unwrap());
577    }
578
579    #[test]
580    fn parse_propagation_default_conservative() {
581        // Ambiguous response defaults to true (conservative)
582        assert!(parse_propagation_result("I'm not sure about this one").unwrap());
583    }
584
585    // ── BreakingVerdict parsing ─────────────────────────────────────
586
587    // ── File behavioral response parsing ────────────────────────────
588
589    #[test]
590    fn parse_file_behavioral_empty() {
591        let response = r#"```json
592{"breaking_behavioral_changes": [], "breaking_api_changes": []}
593```"#;
594        let (beh, api) = parse_file_behavioral_response(response).unwrap();
595        assert!(beh.is_empty());
596        assert!(api.is_empty());
597    }
598
599    #[test]
600    fn parse_file_behavioral_with_changes() {
601        let response = r#"```json
602{
603  "breaking_behavioral_changes": [
604    {
605      "symbol": "Modal",
606      "kind": "class",
607      "description": "Component now renders a <section> instead of <div>"
608    },
609    {
610      "symbol": "closeModal",
611      "kind": "function",
612      "description": "No longer emits 'beforeClose' event"
613    }
614  ],
615  "breaking_api_changes": [
616    {
617      "symbol": "ModalProps.size",
618      "change": "type_changed",
619      "description": "Type narrowed from string to union"
620    }
621  ]
622}
623```"#;
624        let (beh, api) = parse_file_behavioral_response(response).unwrap();
625        assert_eq!(beh.len(), 2);
626        assert_eq!(beh[0].symbol, "Modal");
627        assert_eq!(beh[0].kind, "class");
628        assert!(beh[0].description.contains("section"));
629        assert_eq!(beh[1].symbol, "closeModal");
630        assert_eq!(beh[1].kind, "function");
631        assert_eq!(api.len(), 1);
632        assert_eq!(api[0].symbol, "ModalProps.size");
633        assert_eq!(api[0].change, "type_changed");
634    }
635
636    #[test]
637    fn parse_file_behavioral_default_kind() {
638        let response =
639            r#"{"breaking_behavioral_changes": [{"symbol": "Foo", "description": "changed"}]}"#;
640        let (beh, _api) = parse_file_behavioral_response(response).unwrap();
641        assert_eq!(beh[0].kind, "class");
642    }
643
644    #[test]
645    fn parse_file_no_api_field_ok() {
646        // Old-format response without breaking_api_changes should still work
647        let response = r#"{"breaking_behavioral_changes": []}"#;
648        let (beh, api) = parse_file_behavioral_response(response).unwrap();
649        assert!(beh.is_empty());
650        assert!(api.is_empty());
651    }
652
653    // ── Tier 1 structured field parsing ─────────────────────────────
654
655    #[test]
656    fn parse_removal_disposition_moved_to_child() {
657        let response = r#"```json
658{
659  "breaking_behavioral_changes": [],
660  "breaking_api_changes": [
661    {
662      "symbol": "ModalProps.actions",
663      "change": "removed",
664      "description": "actions prop removed, pass as children of ModalFooter",
665      "removal_disposition": {
666        "type": "moved_to_related_type",
667        "target_type": "ModalFooter",
668        "mechanism": "children"
669      }
670    }
671  ]
672}
673```"#;
674        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
675        assert_eq!(api.len(), 1);
676        assert_eq!(api[0].symbol, "ModalProps.actions");
677        let disp = api[0]
678            .removal_disposition
679            .as_ref()
680            .expect("Should have disposition");
681        match disp {
682            RemovalDisposition::MovedToRelatedType {
683                target_type,
684                mechanism,
685            } => {
686                assert_eq!(target_type, "ModalFooter");
687                assert_eq!(mechanism, "children");
688            }
689            _ => panic!("Expected MovedToRelatedType, got {:?}", disp),
690        }
691    }
692
693    #[test]
694    fn parse_removal_disposition_moved_to_child_as_prop() {
695        let response = r#"```json
696{
697  "breaking_behavioral_changes": [],
698  "breaking_api_changes": [
699    {
700      "symbol": "ModalProps.title",
701      "change": "removed",
702      "description": "title prop moved to ModalHeader",
703      "removal_disposition": {
704        "type": "moved_to_related_type",
705        "target_type": "ModalHeader",
706        "mechanism": "prop"
707      }
708    }
709  ]
710}
711```"#;
712        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
713        let disp = api[0].removal_disposition.as_ref().unwrap();
714        match disp {
715            RemovalDisposition::MovedToRelatedType {
716                target_type,
717                mechanism,
718            } => {
719                assert_eq!(target_type, "ModalHeader");
720                assert_eq!(mechanism, "prop");
721            }
722            _ => panic!("Expected MovedToRelatedType, got {:?}", disp),
723        }
724    }
725
726    #[test]
727    fn parse_removal_disposition_replaced_by_member() {
728        let response = r#"```json
729{
730  "breaking_behavioral_changes": [],
731  "breaking_api_changes": [
732    {
733      "symbol": "ButtonProps.isFlat",
734      "change": "removed",
735      "description": "isFlat replaced by isPlain",
736      "removal_disposition": {
737        "type": "replaced_by_member",
738        "new_member": "isPlain"
739      }
740    }
741  ]
742}
743```"#;
744        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
745        let disp = api[0].removal_disposition.as_ref().unwrap();
746        match disp {
747            RemovalDisposition::ReplacedByMember { new_member } => {
748                assert_eq!(new_member, "isPlain");
749            }
750            _ => panic!("Expected ReplacedByMember, got {:?}", disp),
751        }
752    }
753
754    #[test]
755    fn parse_removal_disposition_truly_removed() {
756        let response = r#"```json
757{
758  "breaking_behavioral_changes": [],
759  "breaking_api_changes": [
760    {
761      "symbol": "ModalProps.showClose",
762      "change": "removed",
763      "description": "showClose removed, close button now controlled by onClose presence",
764      "removal_disposition": {"type": "truly_removed"}
765    }
766  ]
767}
768```"#;
769        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
770        let disp = api[0].removal_disposition.as_ref().unwrap();
771        assert!(matches!(disp, RemovalDisposition::TrulyRemoved));
772    }
773
774    #[test]
775    fn parse_removal_disposition_made_automatic() {
776        let response = r#"```json
777{
778  "breaking_behavioral_changes": [],
779  "breaking_api_changes": [
780    {
781      "symbol": "SelectProps.isDynamic",
782      "change": "removed",
783      "description": "isDynamic now inferred automatically",
784      "removal_disposition": {"type": "made_automatic"}
785    }
786  ]
787}
788```"#;
789        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
790        let disp = api[0].removal_disposition.as_ref().unwrap();
791        assert!(matches!(disp, RemovalDisposition::MadeAutomatic));
792    }
793
794    #[test]
795    fn parse_removal_disposition_null_is_none() {
796        let response = r#"```json
797{
798  "breaking_behavioral_changes": [],
799  "breaking_api_changes": [
800    {
801      "symbol": "FooProps.bar",
802      "change": "removed",
803      "description": "bar removed",
804      "removal_disposition": null
805    }
806  ]
807}
808```"#;
809        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
810        assert!(api[0].removal_disposition.is_none());
811    }
812
813    #[test]
814    fn parse_removal_disposition_missing_is_none() {
815        // Backward compat: old responses without the field should still parse
816        let response = r#"```json
817{
818  "breaking_behavioral_changes": [],
819  "breaking_api_changes": [
820    {
821      "symbol": "FooProps.bar",
822      "change": "removed",
823      "description": "bar removed"
824    }
825  ]
826}
827```"#;
828        let (_beh, api) = parse_file_behavioral_response(response).unwrap();
829        assert!(api[0].removal_disposition.is_none());
830    }
831
832    #[test]
833    fn parse_is_internal_only() {
834        let response = r#"```json
835{
836  "breaking_behavioral_changes": [
837    {
838      "symbol": "ClipboardCopyButton",
839      "kind": "class",
840      "category": "render_output",
841      "description": "CopyIcon now passed via icon prop internally",
842      "is_internal_only": true
843    },
844    {
845      "symbol": "Modal",
846      "kind": "class",
847      "category": "dom_structure",
848      "description": "Modal no longer renders ModalBoxBody wrapper",
849      "is_internal_only": false
850    }
851  ],
852  "breaking_api_changes": []
853}
854```"#;
855        let (beh, _api) = parse_file_behavioral_response(response).unwrap();
856        assert_eq!(beh.len(), 2);
857        assert_eq!(beh[0].is_internal_only, Some(true));
858        assert_eq!(beh[1].is_internal_only, Some(false));
859    }
860
861    #[test]
862    fn parse_is_internal_only_missing_is_none() {
863        let response = r#"```json
864{
865  "breaking_behavioral_changes": [
866    {"symbol": "Foo", "kind": "class", "description": "changed"}
867  ],
868  "breaking_api_changes": []
869}
870```"#;
871        let (beh, _api) = parse_file_behavioral_response(response).unwrap();
872        assert!(beh[0].is_internal_only.is_none());
873    }
874
875    #[test]
876    fn parse_verdict_from_json() {
877        let response = r#"```json
878{
879  "is_breaking": true,
880  "reasons": ["postcondition weakened"],
881  "confidence": 0.75
882}
883```"#;
884        let verdict = parse_breaking_verdict(response).unwrap();
885        assert!(verdict.is_breaking);
886        assert_eq!(verdict.reasons.len(), 1);
887        assert!((verdict.confidence - 0.75).abs() < f64::EPSILON);
888    }
889
890    // ── Hierarchy inference tests ───────────────────────────────────
891
892    #[test]
893    fn parse_hierarchy_response_dropdown_family() {
894        let response = r#"```json
895{
896  "components": {
897    "Dropdown": {
898      "expected_children": [
899        { "name": "DropdownList", "required": true },
900        { "name": "DropdownGroup", "required": false }
901      ]
902    },
903    "DropdownList": {
904      "expected_children": [
905        { "name": "DropdownItem", "required": true }
906      ]
907    },
908    "DropdownGroup": {
909      "expected_children": [
910        { "name": "DropdownItem", "required": true }
911      ]
912    },
913    "DropdownItem": {
914      "expected_children": []
915    }
916  }
917}
918```"#;
919        let result = parse_hierarchy_response(response).unwrap();
920        assert_eq!(result.len(), 4);
921
922        let dropdown = &result["Dropdown"];
923        assert_eq!(dropdown.len(), 2);
924        assert_eq!(dropdown[0].name, "DropdownList");
925        assert!(dropdown[0].required);
926        assert_eq!(dropdown[1].name, "DropdownGroup");
927        assert!(!dropdown[1].required);
928
929        let list = &result["DropdownList"];
930        assert_eq!(list.len(), 1);
931        assert_eq!(list[0].name, "DropdownItem");
932
933        let item = &result["DropdownItem"];
934        assert!(item.is_empty());
935    }
936
937    #[test]
938    fn parse_hierarchy_response_modal_family() {
939        let response = r#"```json
940{
941  "components": {
942    "Modal": {
943      "expected_children": [
944        { "name": "ModalHeader", "required": false },
945        { "name": "ModalBody", "required": true },
946        { "name": "ModalFooter", "required": false }
947      ]
948    },
949    "ModalHeader": { "expected_children": [] },
950    "ModalBody": { "expected_children": [] },
951    "ModalFooter": { "expected_children": [] }
952  }
953}
954```"#;
955        let result = parse_hierarchy_response(response).unwrap();
956        assert_eq!(result.len(), 4);
957
958        let modal = &result["Modal"];
959        assert_eq!(modal.len(), 3);
960        assert!(!modal[0].required); // ModalHeader optional
961        assert!(modal[1].required); // ModalBody required
962        assert!(!modal[2].required); // ModalFooter optional
963    }
964
965    #[test]
966    fn parse_hierarchy_response_empty_components() {
967        let response = r#"```json
968{
969  "components": {
970    "Badge": {
971      "expected_children": []
972    }
973  }
974}
975```"#;
976        let result = parse_hierarchy_response(response).unwrap();
977        assert_eq!(result.len(), 1);
978        assert!(result["Badge"].is_empty());
979    }
980
981    // ── Suffix rename response parsing tests ───────────────────────
982
983    #[test]
984    fn parse_suffix_rename_response_valid() {
985        let response = r#"```json
986{
987  "renames": [
988    { "from": "PaddingTop", "to": "PaddingBlockStart" },
989    { "from": "MarginLeft", "to": "MarginInlineStart" }
990  ]
991}
992```"#;
993        let result = parse_suffix_rename_response(response).unwrap();
994        assert_eq!(result.len(), 2);
995        assert_eq!(result[0].from, "PaddingTop");
996        assert_eq!(result[0].to, "PaddingBlockStart");
997        assert_eq!(result[1].from, "MarginLeft");
998        assert_eq!(result[1].to, "MarginInlineStart");
999    }
1000
1001    #[test]
1002    fn parse_suffix_rename_response_empty() {
1003        let response = r#"```json
1004{ "renames": [] }
1005```"#;
1006        let result = parse_suffix_rename_response(response).unwrap();
1007        assert!(result.is_empty());
1008    }
1009
1010    #[test]
1011    fn parse_suffix_rename_response_no_json() {
1012        let response = "I couldn't find any renames.";
1013        let result = parse_suffix_rename_response(response);
1014        assert!(result.is_err());
1015    }
1016}