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
119pub(crate) fn is_valid_explicit_http_url(raw: &str) -> bool {
120 resolve_browser_http_url(raw, DEFAULT_BROWSER_PREFERRED_SCHEME)
121 .is_some_and(|resolved| resolved.inferred_scheme.is_none())
122}
123
124fn classify_browser_address_value(raw: &str) -> BrowserAddressValueKind {
125 let trimmed = raw.trim();
126 if trimmed.is_empty() {
127 BrowserAddressValueKind::Empty
128 } else if trimmed.contains(char::is_whitespace) || !trimmed.contains("://") {
129 BrowserAddressValueKind::SearchQuery
130 } else {
131 BrowserAddressValueKind::Invalid
132 }
133}
134
135pub fn resolve_input(request: BrowserAddressInputRequest) -> BrowserAddressInputResponse {
136 let preferred_scheme =
137 normalize_browser_preferred_scheme(request.context.preferred_scheme.as_deref());
138 let trimmed = request.raw_input.trim();
139
140 if matches!(
142 extract_url_scheme(trimmed).as_deref(),
143 Some(LINGXIA_SCHEME) | Some("file")
144 ) {
145 let url = trimmed.to_string();
146 let action = match request.trigger {
147 BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
148 BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
149 };
150 let state = BrowserAddressState {
151 raw_input: request.raw_input,
152 normalized_input: url.clone(),
153 display_text: url.clone(),
154 value_kind: BrowserAddressValueKind::Url,
155 canonical_url: Some(url.clone()),
156 inferred_scheme: None,
157 };
158 let navigation = BrowserAddressNavigation {
159 url,
160 target: BrowserNavigationTarget::CurrentTab,
161 };
162 return BrowserAddressInputResponse {
163 action,
164 state,
165 navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
166 suggestions: None,
167 error: None,
168 };
169 }
170
171 if let Some(resolved) = resolve_browser_http_url(trimmed, &preferred_scheme) {
172 let BrowserUrlResolution {
173 url: resolved_url,
174 inferred_scheme,
175 } = resolved;
176
177 let action = match request.trigger {
178 BrowserAddressInputTrigger::Submit => BrowserAddressAction::Navigate,
179 BrowserAddressInputTrigger::Edit => BrowserAddressAction::Suggest,
180 };
181
182 let display_text = resolved_url.clone();
183 let state = BrowserAddressState {
184 raw_input: request.raw_input,
185 normalized_input: display_text.clone(),
186 display_text,
187 value_kind: BrowserAddressValueKind::Url,
188 canonical_url: Some(resolved_url.clone()),
189 inferred_scheme,
190 };
191
192 let navigation = BrowserAddressNavigation {
193 url: resolved_url,
194 target: BrowserNavigationTarget::CurrentTab,
195 };
196
197 return BrowserAddressInputResponse {
198 action,
199 state,
200 navigation: matches!(action, BrowserAddressAction::Navigate).then_some(navigation),
201 suggestions: None,
202 error: None,
203 };
204 }
205
206 let value_kind = classify_browser_address_value(trimmed);
207 let normalized = trimmed.to_string();
208 let state = BrowserAddressState {
209 raw_input: request.raw_input,
210 display_text: normalized.clone(),
211 normalized_input: normalized,
212 value_kind,
213 canonical_url: None,
214 inferred_scheme: None,
215 };
216
217 let should_suggest = matches!(request.trigger, BrowserAddressInputTrigger::Edit)
218 || (matches!(value_kind, BrowserAddressValueKind::SearchQuery)
219 && request.context.allow_search_fallback);
220
221 if should_suggest {
222 return BrowserAddressInputResponse {
223 action: BrowserAddressAction::Suggest,
224 state,
225 navigation: None,
226 suggestions: None,
227 error: None,
228 };
229 }
230
231 let error = match value_kind {
232 BrowserAddressValueKind::Empty => BrowserAddressInputError {
233 code: "empty_input".to_string(),
234 message: "Address input is empty".to_string(),
235 },
236 BrowserAddressValueKind::SearchQuery => BrowserAddressInputError {
237 code: "search_fallback_unavailable".to_string(),
238 message: "Search fallback is not enabled for this browser input".to_string(),
239 },
240 BrowserAddressValueKind::Invalid | BrowserAddressValueKind::Url => {
241 BrowserAddressInputError {
242 code: "invalid_url".to_string(),
243 message: "Address input is not a supported URL".to_string(),
244 }
245 }
246 };
247
248 BrowserAddressInputResponse {
249 action: BrowserAddressAction::Reject,
250 state,
251 navigation: None,
252 suggestions: None,
253 error: Some(error),
254 }
255}
256
257pub fn resolve_input_json(request_json: &str) -> Option<String> {
258 let request: BrowserAddressInputRequest = serde_json::from_str(request_json).ok()?;
259 serde_json::to_string(&resolve_input(request)).ok()
260}
261
262#[cfg(test)]
263mod tests {
264 use super::resolve_input;
265 use lingxia_browser::{
266 BrowserAddressAction, BrowserAddressInputContext, BrowserAddressInputRequest,
267 BrowserAddressInputTrigger, BrowserAddressValueKind,
268 };
269
270 #[test]
271 fn submit_without_scheme_navigates_with_https() {
272 let response = resolve_input(BrowserAddressInputRequest {
273 raw_input: "example.com/docs".to_string(),
274 trigger: BrowserAddressInputTrigger::Submit,
275 context: BrowserAddressInputContext::default(),
276 });
277
278 assert_eq!(response.action, BrowserAddressAction::Navigate);
279 assert_eq!(
280 response.navigation.as_ref().map(|value| value.url.as_str()),
281 Some("https://example.com/docs")
282 );
283 assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
284 assert_eq!(response.state.inferred_scheme.as_deref(), Some("https"));
285 }
286
287 #[test]
288 fn submit_keeps_http_fragments() {
289 let response = resolve_input(BrowserAddressInputRequest {
290 raw_input: "http://example.com/path?q=1#frag".to_string(),
291 trigger: BrowserAddressInputTrigger::Submit,
292 context: BrowserAddressInputContext::default(),
293 });
294
295 assert_eq!(response.action, BrowserAddressAction::Navigate);
296 assert_eq!(
297 response.navigation.as_ref().map(|value| value.url.as_str()),
298 Some("http://example.com/path?q=1#frag")
299 );
300 assert_eq!(response.state.inferred_scheme, None);
301 }
302
303 #[test]
304 fn submit_supports_localhost() {
305 let response = resolve_input(BrowserAddressInputRequest {
306 raw_input: "localhost:3000".to_string(),
307 trigger: BrowserAddressInputTrigger::Submit,
308 context: BrowserAddressInputContext::default(),
309 });
310
311 assert_eq!(response.action, BrowserAddressAction::Navigate);
312 assert_eq!(
313 response.navigation.as_ref().map(|value| value.url.as_str()),
314 Some("https://localhost:3000")
315 );
316 }
317
318 #[test]
319 fn edit_search_query_returns_suggest_action() {
320 let response = resolve_input(BrowserAddressInputRequest {
321 raw_input: "openai docs".to_string(),
322 trigger: BrowserAddressInputTrigger::Edit,
323 context: BrowserAddressInputContext::default(),
324 });
325
326 assert_eq!(response.action, BrowserAddressAction::Suggest);
327 assert_eq!(
328 response.state.value_kind,
329 BrowserAddressValueKind::SearchQuery
330 );
331 assert!(response.navigation.is_none());
332 }
333
334 #[test]
335 fn submit_search_query_rejects_when_fallback_is_disabled() {
336 let response = resolve_input(BrowserAddressInputRequest {
337 raw_input: "openai".to_string(),
338 trigger: BrowserAddressInputTrigger::Submit,
339 context: BrowserAddressInputContext::default(),
340 });
341
342 assert_eq!(response.action, BrowserAddressAction::Reject);
343 assert_eq!(
344 response.error.as_ref().map(|value| value.code.as_str()),
345 Some("search_fallback_unavailable")
346 );
347 assert_eq!(
348 response.state.value_kind,
349 BrowserAddressValueKind::SearchQuery
350 );
351 }
352
353 #[test]
354 fn address_input_submit_lingxia_newtab_navigates() {
355 let response = resolve_input(BrowserAddressInputRequest {
356 raw_input: "lingxia://newtab".to_string(),
357 trigger: BrowserAddressInputTrigger::Submit,
358 context: BrowserAddressInputContext::default(),
359 });
360 assert_eq!(response.action, BrowserAddressAction::Navigate);
361 assert_eq!(response.state.value_kind, BrowserAddressValueKind::Url);
362 assert_eq!(
363 response.navigation.as_ref().map(|n| n.url.as_str()),
364 Some("lingxia://newtab")
365 );
366 }
367
368 #[test]
369 fn address_input_submit_lingxia_transfer_navigates() {
370 let response = resolve_input(BrowserAddressInputRequest {
371 raw_input: "lingxia://downloads".to_string(),
372 trigger: BrowserAddressInputTrigger::Submit,
373 context: BrowserAddressInputContext::default(),
374 });
375 assert_eq!(response.action, BrowserAddressAction::Navigate);
376 assert_eq!(
377 response.navigation.as_ref().map(|n| n.url.as_str()),
378 Some("lingxia://downloads")
379 );
380 }
381
382 #[test]
383 fn submit_file_url_navigates_as_is() {
384 let response = resolve_input(BrowserAddressInputRequest {
385 raw_input: "file:///Users/me/page.html".to_string(),
386 trigger: BrowserAddressInputTrigger::Submit,
387 context: BrowserAddressInputContext::default(),
388 });
389 assert_eq!(response.action, BrowserAddressAction::Navigate);
390 assert_eq!(
391 response.navigation.as_ref().map(|n| n.url.as_str()),
392 Some("file:///Users/me/page.html")
393 );
394 }
395}