use std::sync::OnceLock;
use regex::Regex;
use url::Url;
const VALIDATE_PATTERN: &str = r"^https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)$";
const EXTRACT_PATTERN: &str = r"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)";
fn validate_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(VALIDATE_PATTERN).expect("validate pattern compiles"))
}
fn extract_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(EXTRACT_PATTERN).expect("extract pattern compiles"))
}
pub(crate) fn validate_url(url: &str) -> bool {
let trimmed = url.trim();
if trimmed.is_empty() {
return false;
}
match Url::parse(trimmed) {
Ok(parsed) => parsed.scheme() == "http" || parsed.scheme() == "https",
Err(_) => validate_regex().is_match(trimmed),
}
}
pub(crate) fn normalize_url(url: &str) -> String {
let trimmed = url.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
format!("https://{trimmed}")
}
pub(crate) fn target_url(raw: &str) -> Option<String> {
let trimmed = raw.trim();
let candidate = match scheme_of(trimmed) {
Some(scheme) if is_http(scheme) => {
format!("{}{}", scheme.to_lowercase(), &trimmed[scheme.len()..])
}
Some(_) => return None,
None => normalize_url(trimmed),
};
validate_url(&candidate).then_some(candidate)
}
pub(crate) fn target_in_text(raw: &str) -> Option<String> {
match scheme_of(raw.trim()) {
Some(scheme) if !is_http(scheme) => None,
Some(_) => target_url(raw).or_else(|| extract_url(raw)),
None => extract_url(raw),
}
}
fn scheme_of(url: &str) -> Option<&str> {
if let Some((scheme, _)) = url.split_once("://") {
return valid_scheme(scheme);
}
let (scheme, rest) = url.split_once(':')?;
let scheme = valid_scheme(scheme)?;
(!is_port(rest)).then_some(scheme)
}
fn valid_scheme(scheme: &str) -> Option<&str> {
let mut characters = scheme.chars();
let first = characters.next()?;
(first.is_ascii_alphabetic()
&& characters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
.then_some(scheme)
}
fn is_port(rest: &str) -> bool {
let port = rest.split(['/', '?', '#']).next().unwrap_or(rest);
port.is_empty() || port.bytes().all(|b| b.is_ascii_digit())
}
fn is_http(scheme: &str) -> bool {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
}
pub(crate) fn extract_url(text: &str) -> Option<String> {
if text.is_empty() {
return None;
}
if let Some(found) = extract_regex().find(text) {
return Some(found.as_str().to_string());
}
let normalized = normalize_url(text);
if validate_url(&normalized) {
return Some(normalized);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURES: &str = include_str!("../../fixtures/url.json");
fn cases(section: &str) -> Vec<serde_json::Value> {
let all: serde_json::Value = serde_json::from_str(FIXTURES).expect("fixture JSON");
all[section].as_array().expect("section array").clone()
}
#[test]
fn validate_cases_reproduce() {
for case in cases("validate") {
let input = case["input"].as_str().expect("input");
let expected = case["expected"].as_bool().expect("expected bool");
assert_eq!(validate_url(input), expected, "validate {input:?}");
}
}
#[test]
fn normalize_cases_reproduce() {
for case in cases("normalize") {
let input = case["input"].as_str().expect("input");
let expected = case["expected"].as_str().expect("expected string");
assert_eq!(normalize_url(input), expected, "normalize {input:?}");
}
}
#[test]
fn a_scheme_this_tool_cannot_answer_for_is_refused_as_one() {
for raw in [
"ftp://example.com",
"file:///etc/hosts",
"javascript:alert(1)",
"data:text/html,x",
"ws://example.com/socket",
] {
assert_eq!(target_url(raw), None, "{raw}");
assert_eq!(target_in_text(raw), None, "{raw}");
}
}
#[test]
fn a_scheme_without_an_authority_marker_is_still_a_scheme() {
for raw in [
"mailto:x@y.com",
"MAILTO:x@y.com",
"tel:+15551234",
"about:blank",
"javascript:alert(1)",
"data:text/html,x",
"urn:isbn:0451450523",
] {
assert_eq!(target_url(raw), None, "{raw}");
assert_eq!(target_in_text(raw), None, "{raw}");
}
}
#[test]
fn a_port_is_not_mistaken_for_a_scheme() {
for (raw, expected) in [
("localhost:3000", "https://localhost:3000"),
("localhost:3000/admin", "https://localhost:3000/admin"),
("example.com:8080/path", "https://example.com:8080/path"),
("127.0.0.1:8731/plain", "https://127.0.0.1:8731/plain"),
("example.com:", "https://example.com:"),
("example.com", "https://example.com"),
] {
assert_eq!(target_url(raw).as_deref(), Some(expected), "{raw}");
}
}
#[test]
fn a_colon_in_surrounding_text_does_not_swallow_the_url() {
for raw in [
"warn: https://example.com/x",
"warn:https://example.com/x",
"2026-08-16T00:00:00Z https://example.com/x",
"Line 12: https://example.com/x",
] {
assert_eq!(
target_in_text(raw).as_deref(),
Some("https://example.com/x"),
"{raw}"
);
}
}
#[test]
fn an_upper_case_scheme_is_the_scheme_it_names() {
assert_eq!(
target_url("HTTP://127.0.0.1:8731/plain").as_deref(),
Some("http://127.0.0.1:8731/plain")
);
assert_eq!(
target_url("HTTPS://EXAMPLE.COM").as_deref(),
Some("https://EXAMPLE.COM"),
"only the scheme is folded; the rest of the URL is the caller's"
);
assert_eq!(
target_url("HtTpS://example.com/a?b=C").as_deref(),
Some("https://example.com/a?b=C")
);
}
#[test]
fn a_target_still_takes_what_it_always_took() {
assert_eq!(
target_url("example.com").as_deref(),
Some("https://example.com")
);
assert_eq!(
target_url(" https://example.com ").as_deref(),
Some("https://example.com")
);
assert_eq!(
target_url("localhost:3000").as_deref(),
Some("https://localhost:3000")
);
assert_eq!(target_url("not a url at all"), None);
assert_eq!(target_url("https://"), None);
assert_eq!(target_url(""), None);
}
#[test]
fn a_batch_entry_still_finds_a_url_amid_other_text() {
for case in cases("extract") {
let input = case["input"].as_str().expect("input");
let expected = case["expected"].as_str();
assert_eq!(
target_in_text(input).as_deref(),
expected,
"batch entry {input:?}"
);
}
}
#[test]
fn extract_cases_reproduce() {
for case in cases("extract") {
let input = case["input"].as_str().expect("input");
let expected = case["expected"].as_str();
assert_eq!(extract_url(input).as_deref(), expected, "extract {input:?}");
}
}
}