Skip to main content

llm_output_parser/
xml.rs

1//! XML-style tag extraction from LLM responses.
2//!
3//! Provides [`parse_xml_tag`] and [`parse_xml_tags`] for extracting content
4//! from XML-style structured delimiters in LLM output. Does NOT use a full
5//! XML parser — these are lightweight tag-matching functions.
6
7use std::collections::HashMap;
8
9use crate::error::{ensure_input_within_limits, truncate, ParseError, ParseOptions, ParseTrace};
10use crate::extract::preprocess_opts;
11
12/// Extract content from a single XML-style tag in an LLM response.
13///
14/// Looks for `<tag>content</tag>` after preprocessing.
15/// Handles missing close tags (returns content to end of string).
16/// Does NOT use a full XML parser — these are structured delimiters.
17///
18/// # Examples
19///
20/// ```
21/// use llm_output_parser::parse_xml_tag;
22///
23/// let response = "<answer>The capital is Paris.</answer>";
24/// let answer = parse_xml_tag(response, "answer").unwrap();
25/// assert_eq!(answer, "The capital is Paris.");
26/// ```
27pub 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
33/// Extract content from a single XML-style tag with diagnostic trace.
34pub 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            // No closing tag — take content to end
58            &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
71/// Extract content from multiple XML-style tags into a map.
72///
73/// Returns a `HashMap` of `tag_name -> content` for each tag found.
74/// Missing tags are simply absent from the map (not an error).
75/// At least one tag must be found or returns `ParseError`.
76///
77/// # Examples
78///
79/// ```
80/// use llm_output_parser::parse_xml_tags;
81///
82/// let response = "<analysis>Looks good</analysis><confidence>0.95</confidence>";
83/// let result = parse_xml_tags(response, &["analysis", "confidence"]).unwrap();
84/// assert_eq!(result["analysis"], "Looks good");
85/// assert_eq!(result["confidence"], "0.95");
86/// ```
87pub 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
96/// Extract content from multiple XML-style tags with diagnostic trace.
97pub 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}