Skip to main content

claude_codex/providers/codex/translate/
web_search_compat.rs

1use serde_json::Value;
2
3pub struct WebSearchCompatBlock {
4    pub index: usize,
5    pub content: WebSearchCompatContent,
6}
7
8pub enum WebSearchCompatContent {
9    ServerToolUse {
10        id: String,
11        name: String,
12        input: Value,
13    },
14    WebSearchToolResult {
15        tool_use_id: String,
16        content: Vec<WebSearchResult>,
17    },
18}
19
20#[derive(Clone)]
21pub struct WebSearchResult {
22    pub title: String,
23    pub url: String,
24}
25
26pub fn server_tool_use_id_from_codex_web_search_id(id: &str) -> String {
27    let suffix: String = id
28        .chars()
29        .map(|c| {
30            if c.is_alphanumeric() || c == '_' {
31                c
32            } else {
33                '_'
34            }
35        })
36        .collect();
37    format!("srvtoolu_{suffix}")
38}
39
40fn extract_web_search_results_from_text(text: &str) -> Vec<WebSearchResult> {
41    let mut results: Vec<WebSearchResult> = Vec::new();
42    let mut seen_urls: std::collections::HashSet<String> = std::collections::HashSet::new();
43
44    // Match markdown links [title](url)
45    let re = regex_lite::Regex::new(r#"\[([^\]\n]+)\]\((https?://[^)\s]+)\)"#).unwrap();
46    for cap in re.captures_iter(text) {
47        let title = clean_title(cap.get(1).map(|m| m.as_str()).unwrap_or(""));
48        let url = clean_url(cap.get(2).map(|m| m.as_str()).unwrap_or(""));
49        if url.is_empty() || seen_urls.contains(&url) {
50            continue;
51        }
52        seen_urls.insert(url.clone());
53        let display_title = if title.is_empty() {
54            fallback_title(&url)
55        } else {
56            title
57        };
58        results.push(WebSearchResult {
59            title: display_title,
60            url,
61        });
62    }
63
64    // Match bare URLs
65    let re2 = regex_lite::Regex::new(r"https?://[^\s<>()|]+").unwrap();
66    for cap in re2.captures_iter(text) {
67        let raw_url = cap.get(0).map(|m| m.as_str()).unwrap_or("");
68        let url = clean_url(raw_url);
69        if url.is_empty() || seen_urls.contains(&url) {
70            continue;
71        }
72        seen_urls.insert(url.clone());
73        results.push(WebSearchResult {
74            title: fallback_title(&url),
75            url,
76        });
77    }
78
79    results
80}
81
82pub fn build_web_search_compat_blocks(
83    searches: &[super::reducer::ReducerEvent],
84    text: &str,
85) -> Vec<WebSearchCompatBlock> {
86    let results = extract_web_search_results_from_text(text);
87    let mut blocks = Vec::new();
88
89    for event in searches {
90        if let super::reducer::ReducerEvent::WebSearch {
91            index,
92            result_index,
93            id,
94            query,
95        } = event
96        {
97            blocks.push(WebSearchCompatBlock {
98                index: *index,
99                content: WebSearchCompatContent::ServerToolUse {
100                    id: id.clone(),
101                    name: "web_search".to_string(),
102                    input: serde_json::json!({"query": query}),
103                },
104            });
105            blocks.push(WebSearchCompatBlock {
106                index: *result_index,
107                content: WebSearchCompatContent::WebSearchToolResult {
108                    tool_use_id: id.clone(),
109                    content: results.clone(),
110                },
111            });
112        }
113    }
114
115    blocks
116}
117
118fn clean_url(value: &str) -> String {
119    let mut out = value.trim().to_string();
120    while out.ends_with('.')
121        || out.ends_with(',')
122        || out.ends_with(';')
123        || out.ends_with(':')
124        || out.ends_with('!')
125        || out.ends_with('?')
126    {
127        out.pop();
128    }
129    out
130}
131
132fn clean_title(value: &str) -> String {
133    let no_markers = value
134        .trim_start()
135        .trim_start_matches(|c: char| c == '-' || c == '*' || c == '+' || c.is_ascii_digit())
136        .trim_start_matches(['.', ')', ' '])
137        .replace("**", "")
138        .replace('`', "")
139        .trim()
140        .to_string();
141    // Take text before dash or em-dash
142    if let Some(pos) = no_markers.find(" - ") {
143        no_markers[..pos].trim().to_string()
144    } else if let Some(find_pos) = no_markers.find(" \u{2013} ") {
145        no_markers[..find_pos].trim().to_string()
146    } else {
147        no_markers
148    }
149}
150
151fn fallback_title(url: &str) -> String {
152    url.trim_start_matches("https://")
153        .trim_start_matches("http://")
154        .split('/')
155        .next()
156        .unwrap_or(url)
157        .to_string()
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn server_tool_use_id_format() {
166        let id = server_tool_use_id_from_codex_web_search_id("ws_123");
167        assert_eq!(id, "srvtoolu_ws_123");
168    }
169
170    #[test]
171    fn extract_results_from_text() {
172        let text = "Check [Example](https://example.com) and https://other.com/page.";
173        let results = extract_web_search_results_from_text(text);
174        assert_eq!(results.len(), 2);
175        assert!(results.iter().any(|r| r.url == "https://example.com"));
176        assert!(results.iter().any(|r| r.url == "https://other.com/page"));
177    }
178
179    #[test]
180    fn build_compat_blocks() {
181        let searches = vec![super::super::reducer::ReducerEvent::WebSearch {
182            index: 0,
183            result_index: 1,
184            id: "ws_1".to_string(),
185            query: "test".to_string(),
186        }];
187        let text = "See [Result](https://result.com)";
188        let blocks = build_web_search_compat_blocks(&searches, text);
189        assert_eq!(blocks.len(), 2);
190        match &blocks[0].content {
191            WebSearchCompatContent::ServerToolUse { name, input, .. } => {
192                assert_eq!(name, "web_search");
193                assert_eq!(input.get("query").and_then(|v| v.as_str()), Some("test"));
194            }
195            _ => panic!("expected ServerToolUse"),
196        }
197        match &blocks[1].content {
198            WebSearchCompatContent::WebSearchToolResult { content, .. } => {
199                assert_eq!(content.len(), 1);
200                assert_eq!(content[0].url, "https://result.com");
201            }
202            _ => panic!("expected WebSearchToolResult"),
203        }
204    }
205}