1use std::collections::HashMap;
8
9use crate::error::{ensure_input_within_limits, truncate, ParseError, ParseOptions, ParseTrace};
10use crate::extract::preprocess_opts;
11
12pub fn parse_xml_tag(response: &str, tag: &str) -> Result<String, ParseError> {
28 let opts = ParseOptions::default();
29 let (value, _trace) = parse_xml_tag_with_trace(response, tag, &opts)?;
30 Ok(value)
31}
32
33pub fn parse_xml_tag_with_trace(
35 response: &str,
36 tag: &str,
37 opts: &ParseOptions,
38) -> Result<(String, ParseTrace), ParseError> {
39 ensure_input_within_limits(response, opts)?;
40
41 let cleaned = preprocess_opts(response, opts.strip_think_tags);
42 let mut trace = ParseTrace::default();
43
44 if cleaned.is_empty() {
45 return Err(ParseError::EmptyResponse);
46 }
47
48 let open_tag = format!("<{}>", tag);
49 let close_tag = format!("</{}>", tag);
50 trace.strategies_tried.push("tag_lookup");
51
52 if let Some(start) = cleaned.find(&open_tag) {
53 let content_start = start + open_tag.len();
54 let content = if let Some(end) = cleaned[content_start..].find(&close_tag) {
55 &cleaned[content_start..content_start + end]
56 } else {
57 &cleaned[content_start..]
59 };
60 let trimmed = content.trim();
61 trace.extracted_span = Some((content_start, content_start + content.len()));
62 return Ok((trimmed.to_string(), trace));
63 }
64
65 Err(ParseError::Unparseable {
66 expected_format: "XML tag",
67 text: truncate(&cleaned, 200),
68 })
69}
70
71pub fn parse_xml_tags(
88 response: &str,
89 tags: &[&str],
90) -> Result<HashMap<String, String>, ParseError> {
91 let opts = ParseOptions::default();
92 let (values, _trace) = parse_xml_tags_with_trace(response, tags, &opts)?;
93 Ok(values)
94}
95
96pub fn parse_xml_tags_with_trace(
98 response: &str,
99 tags: &[&str],
100 opts: &ParseOptions,
101) -> Result<(HashMap<String, String>, ParseTrace), ParseError> {
102 ensure_input_within_limits(response, opts)?;
103
104 let cleaned = preprocess_opts(response, opts.strip_think_tags);
105 let mut trace = ParseTrace::default();
106
107 if cleaned.is_empty() {
108 return Err(ParseError::EmptyResponse);
109 }
110
111 let mut results = HashMap::new();
112 trace.strategies_tried.push("multi_tag_lookup");
113
114 for &tag in tags {
115 let open_tag = format!("<{}>", tag);
116 let close_tag = format!("</{}>", tag);
117
118 if let Some(start) = cleaned.find(&open_tag) {
119 let content_start = start + open_tag.len();
120 let content = if let Some(end) = cleaned[content_start..].find(&close_tag) {
121 &cleaned[content_start..content_start + end]
122 } else {
123 &cleaned[content_start..]
124 };
125 if trace.extracted_span.is_none() {
126 trace.extracted_span = Some((content_start, content_start + content.len()));
127 }
128 results.insert(tag.to_string(), content.trim().to_string());
129 }
130 }
131
132 if results.is_empty() {
133 return Err(ParseError::Unparseable {
134 expected_format: "XML tags",
135 text: truncate(&cleaned, 200),
136 });
137 }
138
139 for tag in tags {
140 if !results.contains_key(*tag) {
141 trace.warnings.push(format!("tag '{}' not found", tag));
142 }
143 }
144
145 Ok((results, trace))
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn simple_tag() {
154 let result = parse_xml_tag("<answer>Paris</answer>", "answer").unwrap();
155 assert_eq!(result, "Paris");
156 }
157
158 #[test]
159 fn think_then_tag() {
160 let result =
161 parse_xml_tag("<think>reasoning</think><answer>Paris</answer>", "answer").unwrap();
162 assert_eq!(result, "Paris");
163 }
164
165 #[test]
166 fn multiple_tags() {
167 let result = parse_xml_tags("<a>one</a><b>two</b>", &["a", "b"]).unwrap();
168 assert_eq!(result["a"], "one");
169 assert_eq!(result["b"], "two");
170 }
171
172 #[test]
173 fn nested_content() {
174 let result = parse_xml_tag("<answer>The answer is <b>bold</b></answer>", "answer").unwrap();
175 assert_eq!(result, "The answer is <b>bold</b>");
176 }
177
178 #[test]
179 fn missing_close() {
180 let result = parse_xml_tag("<answer>Paris", "answer").unwrap();
181 assert_eq!(result, "Paris");
182 }
183
184 #[test]
185 fn multiline_content() {
186 let result = parse_xml_tag("<code>\nfn main() {}\n</code>", "code").unwrap();
187 assert_eq!(result, "fn main() {}");
188 }
189
190 #[test]
191 fn whitespace_trimming() {
192 let result = parse_xml_tag("<answer> Paris </answer>", "answer").unwrap();
193 assert_eq!(result, "Paris");
194 }
195
196 #[test]
197 fn tag_not_found() {
198 let result = parse_xml_tag("<wrong>data</wrong>", "answer");
199 assert!(result.is_err());
200 }
201
202 #[test]
203 fn partial_tags_found() {
204 let result = parse_xml_tags("<a>one</a><b>two</b>", &["a", "b", "c"]).unwrap();
205 assert_eq!(result.len(), 2);
206 assert_eq!(result["a"], "one");
207 assert_eq!(result["b"], "two");
208 }
209
210 #[test]
211 fn no_tags_found() {
212 let result = parse_xml_tags("<x>data</x>", &["a", "b"]);
213 assert!(result.is_err());
214 }
215
216 #[test]
217 fn case_sensitive() {
218 let result = parse_xml_tag("<Answer>Paris</Answer>", "answer");
219 assert!(result.is_err());
220 }
221
222 #[test]
223 fn tag_with_trace_records_span() {
224 let opts = ParseOptions::default();
225 let (result, trace) =
226 parse_xml_tag_with_trace("<answer>Paris</answer>", "answer", &opts).unwrap();
227 assert_eq!(result, "Paris");
228 assert!(trace.strategies_tried.contains(&"tag_lookup"));
229 assert!(trace.extracted_span.is_some());
230 }
231
232 #[test]
233 fn tags_with_trace_warn_on_missing_tag() {
234 let opts = ParseOptions::default();
235 let (result, trace) = parse_xml_tags_with_trace("<a>one</a>", &["a", "b"], &opts).unwrap();
236 assert_eq!(result["a"], "one");
237 assert_eq!(trace.warnings, vec!["tag 'b' not found"]);
238 }
239
240 #[test]
241 fn with_trace_rejects_oversized_input() {
242 let opts = ParseOptions {
243 max_input_bytes: 8,
244 ..ParseOptions::default()
245 };
246 let err =
247 parse_xml_tag_with_trace("<answer>too long</answer>", "answer", &opts).unwrap_err();
248 assert_eq!(err.kind(), "too_large");
249 }
250}