1use std::collections::HashSet;
10
11use crate::error::{
12 ensure_input_within_limits, record_extracted_span, truncate, ParseError, ParseOptions,
13 ParseTrace,
14};
15use crate::extract::{extract_code_block, find_bracketed_limited, preprocess_opts};
16use crate::repair::try_repair_json;
17
18pub fn parse_string_list(response: &str) -> Result<Vec<String>, ParseError> {
32 let opts = ParseOptions::default();
33 let (items, _trace) = parse_string_list_with_trace(response, &opts)?;
34 Ok(items)
35}
36
37pub fn parse_string_list_raw(response: &str) -> Result<Vec<String>, ParseError> {
42 let opts = ParseOptions::default();
43 ensure_input_within_limits(response, &opts)?;
44 let items = parse_string_list_inner_traced(response, &opts, &mut ParseTrace::default())?;
45 Ok(items
46 .into_iter()
47 .map(|s| s.trim().to_string())
48 .filter(|s| !s.is_empty())
49 .collect())
50}
51
52pub fn parse_string_list_with_trace(
62 response: &str,
63 opts: &ParseOptions,
64) -> Result<(Vec<String>, ParseTrace), ParseError> {
65 ensure_input_within_limits(response, opts)?;
66
67 let mut trace = ParseTrace::default();
68 let items = parse_string_list_inner_traced(response, opts, &mut trace)?;
69 Ok((clean_tags(items), trace))
70}
71
72fn parse_string_list_inner_traced(
74 response: &str,
75 opts: &ParseOptions,
76 trace: &mut ParseTrace,
77) -> Result<Vec<String>, ParseError> {
78 let trimmed = response.trim();
79
80 if trimmed.is_empty() {
81 return Err(ParseError::EmptyResponse);
82 }
83
84 trace.strategies_tried.push("direct_json_array");
86 if let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed) {
87 trace.extracted_span = Some((0, trimmed.len()));
88 return Ok(arr);
89 }
90
91 let cleaned = preprocess_opts(trimmed, opts.strip_think_tags);
93
94 if cleaned.is_empty() {
95 return Err(ParseError::EmptyResponse);
96 }
97
98 trace.strategies_tried.push("json_array_preprocessed");
100 if let Ok(arr) = serde_json::from_str::<Vec<String>>(&cleaned) {
101 trace.extracted_span = Some((0, cleaned.len()));
102 return Ok(arr);
103 }
104
105 trace.strategies_tried.push("json_object_list_keys");
107 if let Some(tags) = try_extract_list_from_object(&cleaned) {
108 trace
109 .warnings
110 .push("list extracted from object wrapper".to_string());
111 return Ok(tags);
112 }
113
114 if opts.allow_code_fences {
116 trace.strategies_tried.push("code_block_list");
117 if let Some(tags) = extract_list_from_code_block(&cleaned) {
118 return Ok(tags);
119 }
120 }
121
122 trace.strategies_tried.push("bracket_match_array");
124 if let Some(bracket_str) = find_bracketed_limited(&cleaned, '[', ']', opts.max_nesting_depth)? {
125 record_extracted_span(trace, &cleaned, bracket_str);
126 if let Ok(arr) = serde_json::from_str::<Vec<String>>(bracket_str) {
127 return Ok(arr);
128 }
129 if opts.max_repair_attempts > 0 {
131 trace.strategies_tried.push("repair_bracket");
132 if let Some(repaired) = try_repair_json(bracket_str) {
133 trace.repaired = true;
134 trace
135 .repair_actions
136 .push("repaired_bracket_array".to_string());
137 if let Ok(arr) = serde_json::from_str::<Vec<String>>(&repaired) {
138 return Ok(arr);
139 }
140 }
141 }
142 }
143
144 if opts.max_repair_attempts > 0 {
146 trace.strategies_tried.push("repair_cleaned");
147 if let Some(repaired) = try_repair_json(&cleaned) {
148 trace.repaired = true;
149 trace
150 .repair_actions
151 .push("repaired_cleaned_text".to_string());
152 if let Ok(arr) = serde_json::from_str::<Vec<String>>(&repaired) {
153 return Ok(arr);
154 }
155 if let Some(tags) = try_extract_list_from_object(&repaired) {
156 return Ok(tags);
157 }
158 }
159 }
160
161 trace.strategies_tried.push("numbered_bulleted_list");
163 if let Some(tags) = extract_from_list(&cleaned) {
164 return Ok(tags);
165 }
166
167 trace.strategies_tried.push("comma_separated");
169 let tags: Vec<String> = cleaned
170 .split(',')
171 .map(|s| s.trim().trim_matches('"').trim().to_string())
172 .filter(|s| !s.is_empty())
173 .collect();
174
175 if tags.is_empty() {
176 return Err(ParseError::Unparseable {
177 expected_format: "string list",
178 text: truncate(&cleaned, 200),
179 });
180 }
181
182 trace
183 .warnings
184 .push("fell back to comma-separated parsing".to_string());
185 Ok(tags)
186}
187
188fn try_extract_list_from_object(text: &str) -> Option<Vec<String>> {
190 let val: serde_json::Value = serde_json::from_str(text).ok()?;
191 for key in ["tags", "items", "results", "list"] {
192 if let Some(arr) = val.get(key).and_then(|v| v.as_array()) {
193 let tags: Vec<String> = arr
194 .iter()
195 .filter_map(|v| v.as_str().map(|s| s.to_string()))
196 .collect();
197 if !tags.is_empty() {
198 return Some(tags);
199 }
200 }
201 }
202 None
203}
204
205fn extract_list_from_code_block(text: &str) -> Option<Vec<String>> {
207 if let Some((lang, content)) = extract_code_block(text) {
209 if let Ok(arr) = serde_json::from_str::<Vec<String>>(content) {
211 return Some(arr);
212 }
213 if let Some(tags) = try_extract_list_from_object(content) {
215 return Some(tags);
216 }
217 if lang == Some("json") {
219 if let Some(repaired) = try_repair_json(content) {
220 if let Ok(arr) = serde_json::from_str::<Vec<String>>(&repaired) {
221 return Some(arr);
222 }
223 }
224 }
225 }
226 None
227}
228
229fn extract_from_list(text: &str) -> Option<Vec<String>> {
231 let lines: Vec<&str> = text.lines().collect();
232 let list_items: Vec<String> = lines
233 .iter()
234 .filter_map(|line| {
235 let trimmed = line.trim();
236 if let Some(rest) = trimmed
238 .strip_prefix(|c: char| c.is_ascii_digit())
239 .and_then(|s| {
240 let s = s.trim_start_matches(|c: char| c.is_ascii_digit());
242 s.strip_prefix('.').or_else(|| s.strip_prefix(')'))
243 })
244 {
245 let tag = rest.trim().trim_matches('"').trim();
246 if !tag.is_empty() {
247 return Some(tag.to_string());
248 }
249 }
250 for prefix in ["-", "*", "\u{2022}"] {
252 if let Some(rest) = trimmed.strip_prefix(prefix) {
253 let tag = rest.trim().trim_matches('"').trim();
254 if !tag.is_empty() {
255 return Some(tag.to_string());
256 }
257 }
258 }
259 None
260 })
261 .collect();
262
263 if list_items.len() >= 2 {
264 Some(list_items)
265 } else {
266 None
267 }
268}
269
270fn clean_tags(tags: Vec<String>) -> Vec<String> {
272 let mut seen = HashSet::new();
273 tags.into_iter()
274 .map(|t| t.trim().to_lowercase())
275 .filter(|t| !t.is_empty() && t.len() < 50 && seen.insert(t.clone()))
276 .collect()
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
290 fn parse_json_array() {
291 let input = r#"["portrait", "fantasy", "dark lighting"]"#;
292 let tags = parse_string_list(input).unwrap();
293 assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
294 }
295
296 #[test]
299 fn parse_with_think_blocks() {
300 let input = r#"<think>
301Let me analyze this image. I see a portrait with dark lighting...
302</think>
303
304["portrait", "dark lighting", "woman"]"#;
305 let tags = parse_string_list(input).unwrap();
306 assert_eq!(tags, vec!["portrait", "dark lighting", "woman"]);
307 }
308
309 #[test]
310 fn parse_with_incomplete_think_block() {
311 let input = "<think>\nStill thinking...\n[\"portrait\", \"fantasy\"]";
312 let result = parse_string_list(input);
313 assert!(result.is_err());
314 }
315
316 #[test]
317 fn strip_think_tags_complete() {
318 let input = "<think>reasoning</think>result";
319 assert_eq!(crate::strip_think_tags(input), "result");
320 }
321
322 #[test]
323 fn strip_think_tags_incomplete() {
324 let input = "<think>reasoning without close";
325 assert_eq!(crate::strip_think_tags(input), "");
326 }
327
328 #[test]
329 fn strip_think_tags_multiple() {
330 let input = "<think>first</think>middle<think>second</think>end";
331 assert_eq!(crate::strip_think_tags(input), "middleend");
332 }
333
334 #[test]
337 fn parse_object_with_tags_key() {
338 let input = r#"{"tags": ["portrait", "dark", "moody"]}"#;
339 let tags = parse_string_list(input).unwrap();
340 assert_eq!(tags, vec!["portrait", "dark", "moody"]);
341 }
342
343 #[test]
344 fn parse_think_then_object() {
345 let input = r#"<think>Looking at this...</think>{"tags": ["cat", "cute", "indoor"]}"#;
346 let tags = parse_string_list(input).unwrap();
347 assert_eq!(tags, vec!["cat", "cute", "indoor"]);
348 }
349
350 #[test]
353 fn parse_markdown_code_block() {
354 let input =
355 "Here are the tags:\n\n```json\n[\"portrait\", \"fantasy\", \"oil painting\"]\n```";
356 let tags = parse_string_list(input).unwrap();
357 assert_eq!(tags, vec!["portrait", "fantasy", "oil painting"]);
358 }
359
360 #[test]
361 fn parse_think_then_code_block() {
362 let input = "<think>\nAnalyzing...\n</think>\n\n```json\n[\"landscape\", \"sunset\"]\n```";
363 let tags = parse_string_list(input).unwrap();
364 assert_eq!(tags, vec!["landscape", "sunset"]);
365 }
366
367 #[test]
368 fn parse_code_block_with_object() {
369 let input = "```json\n{\"tags\": [\"a\", \"b\"]}\n```";
370 let tags = parse_string_list(input).unwrap();
371 assert_eq!(tags, vec!["a", "b"]);
372 }
373
374 #[test]
377 fn parse_with_surrounding_text() {
378 let input = r#"Here are the tags: ["cat", "cute", "indoor"]"#;
379 let tags = parse_string_list(input).unwrap();
380 assert_eq!(tags, vec!["cat", "cute", "indoor"]);
381 }
382
383 #[test]
384 fn parse_mixed_text_and_json() {
385 let input = "I found these:\n[\"a\", \"b\"]\nHope that helps!";
386 let tags = parse_string_list(input).unwrap();
387 assert_eq!(tags, vec!["a", "b"]);
388 }
389
390 #[test]
393 fn parse_numbered_list() {
394 let input = "1. portrait\n2. fantasy\n3. dark lighting";
395 let tags = parse_string_list(input).unwrap();
396 assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
397 }
398
399 #[test]
400 fn parse_bulleted_list() {
401 let input = "- portrait\n- fantasy\n- dark lighting";
402 let tags = parse_string_list(input).unwrap();
403 assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
404 }
405
406 #[test]
407 fn parse_star_bulleted_list() {
408 let input = "* cat\n* cute\n* fluffy";
409 let tags = parse_string_list(input).unwrap();
410 assert_eq!(tags, vec!["cat", "cute", "fluffy"]);
411 }
412
413 #[test]
416 fn parse_comma_separated() {
417 let input = "portrait, fantasy, dark lighting";
418 let tags = parse_string_list(input).unwrap();
419 assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
420 }
421
422 #[test]
425 fn parse_empty_fails() {
426 assert!(parse_string_list("").is_err());
427 assert!(parse_string_list(" ").is_err());
428 }
429
430 #[test]
431 fn parse_cleans_whitespace_and_case() {
432 let input = r#"[" Portrait ", " FANTASY ", "Dark Lighting"]"#;
433 let tags = parse_string_list(input).unwrap();
434 assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
435 }
436
437 #[test]
438 fn parse_deduplicates() {
439 let input = r#"["cat", "Cat", "CAT", "dog"]"#;
440 let tags = parse_string_list(input).unwrap();
441 assert_eq!(tags, vec!["cat", "dog"]);
442 }
443
444 #[test]
445 fn parse_filters_long_tags() {
446 let input = format!(r#"["good", "{}"]"#, "x".repeat(60));
447 let tags = parse_string_list(&input).unwrap();
448 assert_eq!(tags, vec!["good"]);
449 }
450
451 #[test]
452 fn clean_tags_filters_empty() {
453 let tags = vec!["good".to_string(), "".to_string(), " ".to_string()];
454 let cleaned = clean_tags(tags);
455 assert_eq!(cleaned, vec!["good"]);
456 }
457
458 #[test]
463 fn json_object_with_items_key() {
464 let input = r#"{"items": ["a", "b"]}"#;
465 let tags = parse_string_list(input).unwrap();
466 assert_eq!(tags, vec!["a", "b"]);
467 }
468
469 #[test]
470 fn json_object_with_results_key() {
471 let input = r#"{"results": ["a", "b"]}"#;
472 let tags = parse_string_list(input).unwrap();
473 assert_eq!(tags, vec!["a", "b"]);
474 }
475
476 #[test]
477 fn json_object_with_list_key() {
478 let input = r#"{"list": ["a", "b"]}"#;
479 let tags = parse_string_list(input).unwrap();
480 assert_eq!(tags, vec!["a", "b"]);
481 }
482
483 #[test]
484 fn repaired_json_list() {
485 let input = "['tag1', 'tag2']";
486 let tags = parse_string_list(input).unwrap();
487 assert_eq!(tags, vec!["tag1", "tag2"]);
488 }
489
490 #[test]
491 fn raw_preserves_case() {
492 let input = r#"["Alpha", "Beta"]"#;
493 let tags = parse_string_list_raw(input).unwrap();
494 assert_eq!(tags, vec!["Alpha", "Beta"]);
495 }
496
497 #[test]
498 fn raw_preserves_length() {
499 let long_item = "x".repeat(60);
500 let input = format!(r#"["short", "{}"]"#, long_item);
501 let tags = parse_string_list_raw(&input).unwrap();
502 assert_eq!(tags.len(), 2);
503 assert_eq!(tags[1], long_item);
504 }
505
506 #[test]
507 fn raw_comma_separated() {
508 let input = "Alpha, Beta, Gamma";
509 let tags = parse_string_list_raw(input).unwrap();
510 assert_eq!(tags, vec!["Alpha", "Beta", "Gamma"]);
511 }
512
513 #[test]
514 fn thinking_tag_variant() {
515 let input = r#"<thinking>analyzing...</thinking>["a", "b"]"#;
516 let tags = parse_string_list(input).unwrap();
517 assert_eq!(tags, vec!["a", "b"]);
518 }
519
520 #[test]
525 fn parse_string_list_with_trace_direct() {
526 let input = r#"["alpha", "beta"]"#;
527 let opts = ParseOptions::default();
528 let (tags, trace) = parse_string_list_with_trace(input, &opts).unwrap();
529 assert_eq!(tags, vec!["alpha", "beta"]);
530 assert!(trace.strategies_tried.contains(&"direct_json_array"));
531 assert!(!trace.repaired);
532 }
533
534 #[test]
535 fn parse_string_list_with_trace_repair() {
536 let input = "['tag1', 'tag2']";
537 let opts = ParseOptions::default();
538 let (tags, trace) = parse_string_list_with_trace(input, &opts).unwrap();
539 assert_eq!(tags, vec!["tag1", "tag2"]);
540 assert!(trace.repaired);
541 }
542
543 #[test]
544 fn parse_string_list_with_trace_too_large() {
545 let input = "x".repeat(100);
546 let opts = ParseOptions {
547 max_input_bytes: 50,
548 ..Default::default()
549 };
550 let result = parse_string_list_with_trace(&input, &opts);
551 match result {
552 Err(ParseError::TooLarge {
553 size: 100,
554 limit: 50,
555 }) => {}
556 other => panic!("expected TooLarge, got {other:?}"),
557 }
558 }
559
560 #[test]
561 fn parse_string_list_with_trace_strategies_recorded() {
562 let input = "- alpha\n- beta\n- gamma";
563 let opts = ParseOptions::default();
564 let (tags, trace) = parse_string_list_with_trace(input, &opts).unwrap();
565 assert_eq!(tags, vec!["alpha", "beta", "gamma"]);
566 assert!(trace.strategies_tried.len() >= 3);
568 assert!(trace.strategies_tried.contains(&"numbered_bulleted_list"));
569 }
570}