1use lingxia_browser::{
2 BrowserAddressAction, BrowserAddressInputError, BrowserAddressInputRequest,
3 BrowserAddressInputResponse, BrowserAddressInputTrigger, BrowserAddressNavigation,
4 BrowserAddressState, BrowserAddressValueKind, BrowserNavigationTarget, extract_url_scheme,
5};
6use std::net::IpAddr;
7
8const DEFAULT_BROWSER_PREFERRED_SCHEME: &str = "https";
9const LINGXIA_SCHEME: &str = "lingxia";
10
11#[derive(Debug, Clone)]
12struct BrowserUrlResolution {
13 url: String,
14 inferred_scheme: Option<String>,
15}
16
17fn normalize_browser_preferred_scheme(raw: Option<&str>) -> String {
18 let candidate = raw
19 .map(str::trim)
20 .filter(|value| !value.is_empty())
21 .unwrap_or(DEFAULT_BROWSER_PREFERRED_SCHEME);
22 let lowered = candidate.to_ascii_lowercase();
23 match lowered.as_str() {
24 "http" | "https" => lowered,
25 _ => DEFAULT_BROWSER_PREFERRED_SCHEME.to_string(),
26 }
27}
28
29fn extract_host_from_authority(authority: &str) -> Option<&str> {
30 let authority = authority.rsplit('@').next()?;
31 if authority.is_empty() {
32 return None;
33 }
34
35 if let Some(rest) = authority.strip_prefix('[') {
36 let end = rest.find(']')?;
37 let host = &rest[..end];
38 if host.is_empty() {
39 return None;
40 }
41 let suffix = &rest[end + 1..];
42 if suffix.is_empty() {
43 return Some(host);
44 }
45 if !suffix.starts_with(':') || suffix.len() == 1 {
46 return None;
47 }
48 if suffix[1..].chars().all(|c| c.is_ascii_digit()) {
49 return Some(host);
50 }
51 return None;
52 }
53
54 let host = match authority.rsplit_once(':') {
55 Some((host, port))
56 if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) =>
57 {
58 host
59 }
60 Some(_) => return None,
61 _ => authority,
62 };
63
64 if host.is_empty() { None } else { Some(host) }
65}
66
67fn is_probable_web_host_without_scheme(host: &str) -> bool {
68 if host.eq_ignore_ascii_case("localhost") {
69 return true;
70 }
71 if host.contains('.') {
72 return true;
73 }
74 host.parse::<IpAddr>().is_ok()
75}
76
77fn resolve_browser_http_url(raw: &str, preferred_scheme: &str) -> Option<BrowserUrlResolution> {
78 let trimmed = raw.trim();
79 if trimmed.is_empty() {
80 return None;
81 }
82 if trimmed.chars().any(|c| c.is_whitespace()) {
83 return None;
84 }
85
86 let (candidate, inferred_scheme) = if trimmed.contains("://") {
87 (trimmed.to_string(), None)
88 } else {
89 (
90 format!("{preferred_scheme}://{trimmed}"),
91 Some(preferred_scheme.to_string()),
92 )
93 };
94
95 let (scheme_raw, rest) = candidate.split_once("://")?;
96 let scheme = scheme_raw.to_ascii_lowercase();
97 if !matches!(scheme.as_str(), "http" | "https") {
98 return None;
99 }
100 if rest.is_empty() || rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#') {
101 return None;
102 }
103
104 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
105 let host = extract_host_from_authority(authority)?;
106 if host.trim().is_empty() || host.chars().any(|c| c.is_whitespace()) {
107 return None;
108 }
109 if inferred_scheme.is_some() && !is_probable_web_host_without_scheme(host) {
110 return None;
111 }
112
113 Some(BrowserUrlResolution {
114 url: format!("{scheme}://{rest}"),
115 inferred_scheme,
116 })
117}
118
119fn classify_browser_address_value(raw: &str) -> BrowserAddressValueKind {
120 let trimmed = raw.trim();
121 if trimmed.is_empty() {
122 BrowserAddressValueKind::Empty
123 } else if trimmed.contains(char::is_whitespace) || !trimmed.contains("://") {
124 BrowserAddressValueKind::SearchQuery
125 } else {
126 BrowserAddressValueKind::Invalid
127 }
128}
129
130pub fn resolve_input(request: BrowserAddressInputRequest) -> BrowserAddressInputResponse {
131 let preferred_scheme =
132 normalize_browser_preferred_scheme(request.context.preferred_scheme.as_deref());
133 let trimmed = request.raw_input.trim();
134
135 if matches!(
137 extract_url_scheme(trimmed).as_deref(),
138 Some(LINGXIA_SCHEME) | Some("file")
139 ) {
140 let url = trimmed.to_string();
141 let action = match request.trigger {
142 BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
143 BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
144 };
145 let state = BrowserAddressState {
146 raw_input: request.raw_input,
147 normalized_input: url.clone(),
148 display_text: url.clone(),
149 value_kind: BrowserAddressValueKind::Url,
150 canonical_url: Some(url.clone()),
151 inferred_scheme: None,
152 };
153 let navigation = BrowserAddressNavigation {
154 url,
155 target: BrowserNavigationTarget::CurrentTab,
156 };
157 return BrowserAddressInputResponse {
158 action,
159 state,
160 navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
161 suggestions: None,
162 error: None,
163 };
164 }
165
166 if let Some(resolved) = resolve_browser_http_url(trimmed, &preferred_scheme) {
167 let BrowserUrlResolution {
168 url: resolved_url,
169 inferred_scheme,
170 } = resolved;
171
172 let action = match request.trigger {
173 BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
174 BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
175 };
176
177 let display_text = resolved_url.clone();
178 let state = BrowserAddressState {
179 raw_input: request.raw_input,
180 normalized_input: display_text.clone(),
181 display_text,
182 value_kind: BrowserAddressValueKind::Url,
183 canonical_url: Some(resolved_url.clone()),
184 inferred_scheme,
185 };
186
187 let navigation = BrowserAddressNavigation {
188 url: resolved_url,
189 target: BrowserNavigationTarget::CurrentTab,
190 };
191
192 return BrowserAddressInputResponse {
193 action,
194 state,
195 navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
196 suggestions: None,
197 error: None,
198 };
199 }
200
201 let value_kind = classify_browser_address_value(trimmed);
202 let normalized = trimmed.to_string();
203 let state = BrowserAddressState {
204 raw_input: request.raw_input,
205 display_text: normalized.clone(),
206 normalized_input: normalized,
207 value_kind,
208 canonical_url: None,
209 inferred_scheme: None,
210 };
211
212 let should_suggest = matches!(request.trigger, BrowserAddressInputTrigger::Edit)
213 || (matches!(value_kind, BrowserAddressValueKind::SearchQuery)
214 && request.context.allow_search_fallback);
215
216 if should_suggest {
217 return BrowserAddressInputResponse {
218 action: BrowserAddressAction::Suggest,
219 state,
220 navigation: None,
221 suggestions: None,
222 error: None,
223 };
224 }
225
226 let error = match value_kind {
227 BrowserAddressValueKind::Empty => BrowserAddressInputError {
228 code: "empty_input".to_string(),
229 message: "Address input is empty".to_string(),
230 },
231 BrowserAddressValueKind::SearchQuery => BrowserAddressInputError {
232 code: "search_fallback_unavailable".to_string(),
233 message: "Search fallback is not enabled for this browser input".to_string(),
234 },
235 BrowserAddressValueKind::Invalid | BrowserAddressValueKind::Url => {
236 BrowserAddressInputError {
237 code: "invalid_url".to_string(),
238 message: "Address input is not a supported URL".to_string(),
239 }
240 }
241 };
242
243 BrowserAddressInputResponse {
244 action: BrowserAddressAction::Reject,
245 state,
246 navigation: None,
247 suggestions: None,
248 error: Some(error),
249 }
250}
251
252pub fn resolve_input_json(request_json: &str) -> Option<String> {
253 let request: BrowserAddressInputRequest = serde_json::from_str(request_json).ok()?;
254 serde_json::to_string(&resolve_input(request)).ok()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::resolve_input;
260 use lingxia_browser::{
261 BrowserAddressAction, BrowserAddressInputContext, BrowserAddressInputRequest,
262 BrowserAddressInputTrigger, BrowserAddressValueKind,
263 };
264
265 #[test]
266 fn submit_without_scheme_navigates_with_https() {
267 let response = resolve_input(BrowserAddressInputRequest {
268 raw_input: "example.com/docs".to_string(),
269 trigger: BrowserAddressInputTrigger::Submit,
270 context: BrowserAddressInputContext::default(),
271 });
272
273 assert_eq!(response.action, BrowserAddressAction::Navigate);
274 assert_eq!(
275 response.navigation.as_ref().map(|value| value.url.as_str()),
276 Some("https://example.com/docs")
277 );
278 assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
279 assert_eq!(response.state.inferred_scheme.as_deref(), Some("https"));
280 }
281
282 #[test]
283 fn submit_keeps_http_fragments() {
284 let response = resolve_input(BrowserAddressInputRequest {
285 raw_input: "http://example.com/path?q=1#frag".to_string(),
286 trigger: BrowserAddressInputTrigger::Submit,
287 context: BrowserAddressInputContext::default(),
288 });
289
290 assert_eq!(response.action, BrowserAddressAction::Navigate);
291 assert_eq!(
292 response.navigation.as_ref().map(|value| value.url.as_str()),
293 Some("http://example.com/path?q=1#frag")
294 );
295 assert_eq!(response.state.inferred_scheme, None);
296 }
297
298 #[test]
299 fn submit_supports_localhost() {
300 let response = resolve_input(BrowserAddressInputRequest {
301 raw_input: "localhost:3000".to_string(),
302 trigger: BrowserAddressInputTrigger::Submit,
303 context: BrowserAddressInputContext::default(),
304 });
305
306 assert_eq!(response.action, BrowserAddressAction::Navigate);
307 assert_eq!(
308 response.navigation.as_ref().map(|value| value.url.as_str()),
309 Some("https://localhost:3000")
310 );
311 }
312
313 #[test]
314 fn edit_search_query_returns_suggest_action() {
315 let response = resolve_input(BrowserAddressInputRequest {
316 raw_input: "openai docs".to_string(),
317 trigger: BrowserAddressInputTrigger::Edit,
318 context: BrowserAddressInputContext::default(),
319 });
320
321 assert_eq!(response.action, BrowserAddressAction::Suggest);
322 assert_eq!(
323 response.state.value_kind,
324 BrowserAddressValueKind::SearchQuery
325 );
326 assert!(response.navigation.is_none());
327 }
328
329 #[test]
330 fn submit_search_query_rejects_when_fallback_is_disabled() {
331 let response = resolve_input(BrowserAddressInputRequest {
332 raw_input: "openai".to_string(),
333 trigger: BrowserAddressInputTrigger::Submit,
334 context: BrowserAddressInputContext::default(),
335 });
336
337 assert_eq!(response.action, BrowserAddressAction::Reject);
338 assert_eq!(
339 response.error.as_ref().map(|value| value.code.as_str()),
340 Some("search_fallback_unavailable")
341 );
342 assert_eq!(
343 response.state.value_kind,
344 BrowserAddressValueKind::SearchQuery
345 );
346 }
347
348 #[test]
349 fn address_input_submit_lingxia_newtab_navigates() {
350 let response = resolve_input(BrowserAddressInputRequest {
351 raw_input: "lingxia://newtab".to_string(),
352 trigger: BrowserAddressInputTrigger::Submit,
353 context: BrowserAddressInputContext::default(),
354 });
355 assert_eq!(response.action, BrowserAddressAction::Navigate);
356 assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
357 assert_eq!(
358 response.navigation.as_ref().map(|n| n.url.as_str()),
359 Some("lingxia://newtab")
360 );
361 }
362
363 #[test]
364 fn address_input_submit_lingxia_transfer_navigates() {
365 let response = resolve_input(BrowserAddressInputRequest {
366 raw_input: "lingxia://downloads".to_string(),
367 trigger: BrowserAddressInputTrigger::Submit,
368 context: BrowserAddressInputContext::default(),
369 });
370 assert_eq!(response.action, BrowserAddressAction::Navigate);
371 assert_eq!(
372 response.navigation.as_ref().map(|n| n.url.as_str()),
373 Some("lingxia://downloads")
374 );
375 }
376
377 #[test]
378 fn submit_file_url_navigates_as_is() {
379 let response = resolve_input(BrowserAddressInputRequest {
380 raw_input: "file:///Users/me/page.html".to_string(),
381 trigger: BrowserAddressInputTrigger::Submit,
382 context: BrowserAddressInputContext::default(),
383 });
384 assert_eq!(response.action, BrowserAddressAction::Navigate);
385 assert_eq!(
386 response.navigation.as_ref().map(|n| n.url.as_str()),
387 Some("file:///Users/me/page.html")
388 );
389 }
390}