1use 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
14pub 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 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 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 return Ok(resolve_goose_overflow(&stdout));
66 }
67 Ok(None) => {
68 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
82fn resolve_goose_overflow(stdout: &str) -> String {
88 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 tracing::debug!(
113 path = overflow_path,
114 overflow_lines = overflow_content.lines().count(),
115 "resolved goose overflow file"
116 );
117 overflow_content
118}
119
120pub 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
137pub 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
150pub fn parse_propagation_result(response: &str) -> Result<bool> {
155 let lower = response.to_lowercase();
156
157 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 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 Ok(true)
189}
190
191#[derive(Debug, Clone, Deserialize)]
195pub struct FileBehavioralChange {
196 pub symbol: String,
197 #[serde(default = "default_kind")]
198 pub kind: String,
199 #[serde(default)]
202 pub category: Option<String>,
203 pub description: String,
204 #[serde(default)]
207 pub is_internal_only: Option<bool>,
208}
209
210fn default_kind() -> String {
211 "class".to_string()
212}
213
214#[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 #[serde(default)]
223 pub removal_disposition: Option<RemovalDisposition>,
224}
225
226fn default_change() -> String {
227 "signature_changed".to_string()
228}
229
230#[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
239pub 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
259static FENCED_JSON_RE: LazyLock<Regex> =
263 LazyLock::new(|| Regex::new(r"```(?:json)?\s*\n([\s\S]*?)\n```").unwrap());
264
265static JSON_OBJECT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[\s\S]*\}").unwrap());
267
268fn extract_json(text: &str) -> Option<String> {
275 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 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 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 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
336fn 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#[derive(Debug, Clone, Deserialize)]
349pub struct LlmConstantRenamePattern {
350 #[serde(alias = "match")]
351 pub match_regex: String,
352 pub replace: String,
353}
354
355#[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
370pub 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
385pub 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#[derive(Debug, Clone, Deserialize)]
404pub struct LlmHierarchyResponse {
405 pub components: std::collections::HashMap<String, LlmComponentHierarchy>,
406}
407
408#[derive(Debug, Clone, Deserialize)]
410pub struct LlmComponentHierarchy {
411 #[serde(default)]
412 pub expected_children: Vec<ExpectedChild>,
413}
414
415#[derive(Debug, Clone, Deserialize)]
419pub struct LlmSuffixRename {
420 pub from: String,
421 pub to: String,
422}
423
424#[derive(Debug, Clone, Deserialize)]
426struct LlmSuffixRenameResponse {
427 pub renames: Vec<LlmSuffixRename>,
428}
429
430pub 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
444pub 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 #[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 #[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 #[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 assert!(parse_propagation_result("I'm not sure about this one").unwrap());
583 }
584
585 #[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 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 #[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 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 #[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); assert!(modal[1].required); assert!(!modal[2].required); }
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 #[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}