1use std::sync::{Arc, Mutex};
26use std::time::{Duration, Instant};
27
28use lru::LruCache;
29use serde::Deserialize;
30
31#[derive(Debug, Clone, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct LinkUnderstandingConfig {
35 #[serde(default)]
38 pub enabled: bool,
39 #[serde(default = "default_max_links")]
42 pub max_links_per_turn: usize,
43 #[serde(default = "default_max_bytes")]
47 pub max_bytes: usize,
48 #[serde(default = "default_timeout_ms")]
51 pub timeout_ms: u64,
52 #[serde(default = "default_cache_ttl_secs")]
55 pub cache_ttl_secs: u64,
56 #[serde(default = "default_deny_hosts")]
60 pub deny_hosts: Vec<String>,
61}
62
63impl Default for LinkUnderstandingConfig {
64 fn default() -> Self {
65 Self {
66 enabled: false,
67 max_links_per_turn: default_max_links(),
68 max_bytes: default_max_bytes(),
69 timeout_ms: default_timeout_ms(),
70 cache_ttl_secs: default_cache_ttl_secs(),
71 deny_hosts: default_deny_hosts(),
72 }
73 }
74}
75
76fn default_max_links() -> usize {
77 3
78}
79fn default_max_bytes() -> usize {
80 1024 * 256 }
82fn default_timeout_ms() -> u64 {
83 8_000
84}
85fn default_cache_ttl_secs() -> u64 {
86 600
87}
88fn default_deny_hosts() -> Vec<String> {
89 vec![
90 "localhost".into(),
91 "127.0.0.1".into(),
92 "0.0.0.0".into(),
93 "169.254.0.0".into(), "metadata.google.internal".into(),
95 ]
96}
97
98#[derive(Clone)]
100struct CacheEntry {
101 summary: Arc<str>,
102 inserted_at: Instant,
103}
104
105#[derive(Debug, Clone)]
107pub struct LinkSummary {
108 pub url: String,
109 pub title: Option<String>,
110 pub body: String,
111}
112
113pub struct LinkExtractor {
117 http: reqwest::Client,
118 cache: Mutex<LruCache<String, CacheEntry>>,
119 cache_ttl: Duration,
120 cache_capacity: usize,
121}
122
123const DEFAULT_CACHE_CAPACITY: usize = 256;
124
125impl LinkExtractor {
126 pub fn new(cfg: &LinkUnderstandingConfig) -> Self {
127 let http = reqwest::Client::builder()
128 .timeout(Duration::from_millis(cfg.timeout_ms))
129 .redirect(reqwest::redirect::Policy::limited(5))
130 .user_agent("nexo-link-understanding/0.1")
131 .build()
132 .unwrap_or_else(|e| {
133 tracing::warn!(error = %e, "link extractor: reqwest build failed; using default");
134 reqwest::Client::new()
135 });
136 Self {
137 http,
138 cache: Mutex::new(LruCache::new(
139 std::num::NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("cap > 0"),
140 )),
141 cache_ttl: Duration::from_secs(cfg.cache_ttl_secs),
142 cache_capacity: DEFAULT_CACHE_CAPACITY,
143 }
144 }
145
146 pub fn cache_capacity(&self) -> usize {
148 self.cache_capacity
149 }
150
151 pub async fn fetch(&self, url: &str, cfg: &LinkUnderstandingConfig) -> Option<LinkSummary> {
155 if !cfg.enabled {
156 return None;
157 }
158 if !host_allowed(url, &cfg.deny_hosts) {
159 crate::telemetry::inc_link_fetch("blocked");
160 return None;
161 }
162
163 if cfg.cache_ttl_secs > 0 {
168 let mut cache = self.cache.lock().ok()?;
169 if let Some(entry) = cache.get(url) {
170 if entry.inserted_at.elapsed() < self.cache_ttl {
171 crate::telemetry::inc_link_cache(true);
172 return Some(LinkSummary {
173 url: url.to_string(),
174 title: None,
175 body: entry.summary.to_string(),
176 });
177 }
178 }
179 crate::telemetry::inc_link_cache(false);
180 }
181
182 let started = std::time::Instant::now();
183 let resp = match self.http.get(url).send().await {
184 Ok(r) => r,
185 Err(e) => {
186 let result = if e.is_timeout() { "timeout" } else { "error" };
187 crate::telemetry::inc_link_fetch(result);
188 crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
189 return None;
190 }
191 };
192 if !resp.status().is_success() {
193 crate::telemetry::inc_link_fetch("error");
194 crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
195 return None;
196 }
197 let content_type = resp
198 .headers()
199 .get(reqwest::header::CONTENT_TYPE)
200 .and_then(|v| v.to_str().ok())
201 .unwrap_or("")
202 .to_lowercase();
203 if !content_type.contains("text/html")
206 && !content_type.contains("text/plain")
207 && !content_type.is_empty()
208 {
209 crate::telemetry::inc_link_fetch("non_html");
210 crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
211 return None;
212 }
213
214 let body = match read_capped(resp, cfg.max_bytes).await {
215 Ok(b) => b,
216 Err(_) => {
217 crate::telemetry::inc_link_fetch("error");
218 crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
219 return None;
220 }
221 };
222 let truncated = body.len() >= cfg.max_bytes;
223 let extracted = extract_main_text(&body, cfg.max_bytes);
224 if extracted.is_empty() {
225 let result = if truncated { "too_big" } else { "non_html" };
226 crate::telemetry::inc_link_fetch(result);
227 crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
228 return None;
229 }
230
231 if cfg.cache_ttl_secs > 0 {
232 if let Ok(mut cache) = self.cache.lock() {
233 cache.put(
234 url.to_string(),
235 CacheEntry {
236 summary: Arc::from(extracted.as_str()),
237 inserted_at: Instant::now(),
238 },
239 );
240 }
241 }
242 crate::telemetry::inc_link_fetch("ok");
243 crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
244 Some(LinkSummary {
245 url: url.to_string(),
246 title: extract_title(&body),
247 body: extracted,
248 })
249 }
250}
251
252pub fn detect_urls(text: &str, max: usize) -> Vec<String> {
256 let mut out: Vec<String> = Vec::new();
261 let mut seen = std::collections::HashSet::new();
262 let mut i = 0;
263 let bytes = text.as_bytes();
264 while i < bytes.len() {
265 let rest = &text[i..];
266 let start_https = rest.find("https://");
267 let start_http = rest.find("http://");
268 let start = match (start_https, start_http) {
269 (Some(a), Some(b)) => Some(a.min(b)),
270 (a, b) => a.or(b),
271 };
272 let Some(rel) = start else { break };
273 let abs_start = i + rel;
274 let after = &text[abs_start..];
275 let end = after
276 .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'')
277 .unwrap_or(after.len());
278 let mut url = &after[..end];
279 while let Some(stripped) = url
282 .strip_suffix(',')
283 .or_else(|| url.strip_suffix('.'))
284 .or_else(|| url.strip_suffix(';'))
285 .or_else(|| url.strip_suffix(':'))
286 .or_else(|| url.strip_suffix(')'))
287 .or_else(|| url.strip_suffix(']'))
288 .or_else(|| url.strip_suffix('}'))
289 .or_else(|| url.strip_suffix('?'))
290 .or_else(|| url.strip_suffix('!'))
291 {
292 url = stripped;
293 }
294 if url.len() > 2048 {
295 i = abs_start + end;
297 continue;
298 }
299 if seen.insert(url.to_string()) {
300 out.push(url.to_string());
301 if out.len() >= max {
302 break;
303 }
304 }
305 i = abs_start + end;
306 }
307 out
308}
309
310fn host_allowed(url: &str, deny: &[String]) -> bool {
311 let after_scheme = url
313 .strip_prefix("https://")
314 .or_else(|| url.strip_prefix("http://"))
315 .unwrap_or(url);
316 let host = after_scheme
317 .split(['/', '?', '#'])
318 .next()
319 .unwrap_or("")
320 .split('@')
321 .next_back()
322 .unwrap_or("")
323 .split(':')
324 .next()
325 .unwrap_or("")
326 .to_lowercase();
327 if host.is_empty() {
328 return false;
329 }
330 !deny.iter().any(|pat| {
331 host == pat.to_lowercase() || host.ends_with(&format!(".{}", pat.to_lowercase()))
332 })
333}
334
335async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<String, reqwest::Error> {
336 use futures::stream::StreamExt;
337 let mut stream = resp.bytes_stream();
338 let mut buf: Vec<u8> = Vec::with_capacity(cap.min(64 * 1024));
339 while let Some(chunk) = stream.next().await {
340 let chunk = chunk?;
341 let remaining = cap.saturating_sub(buf.len());
342 if remaining == 0 {
343 break;
344 }
345 let take = remaining.min(chunk.len());
346 buf.extend_from_slice(&chunk[..take]);
347 if buf.len() >= cap {
348 break;
349 }
350 }
351 Ok(String::from_utf8_lossy(&buf).into_owned())
352}
353
354pub fn extract_main_text(html: &str, max_bytes: usize) -> String {
358 let mut cleaned = String::from(html);
373 for tag in [
374 "script", "style", "noscript", "head", "nav", "header", "footer", "aside", "form",
375 "button", "menu", "iframe", "svg", "dialog", "template",
376 ] {
377 cleaned = strip_block(&cleaned, tag);
378 }
379 let cleaned = strip_blocks_by_class_keyword(
384 &cleaned,
385 &[
386 "sidebar",
387 "side-bar",
388 "comment",
389 "advert",
390 "advertisement",
391 "share",
392 "social",
393 "cookie",
394 "popup",
395 "newsletter",
396 "related-article",
397 "related-posts",
398 "navigation",
399 "breadcrumb",
400 "promo",
401 "subscribe",
402 ],
403 );
404
405 let mut buf = String::with_capacity(cleaned.len());
408 for token in tokenize(&cleaned) {
409 match token {
410 Token::Text(s) => buf.push_str(s),
411 Token::Tag(name) => {
412 let lname = name.trim_start_matches('/').to_ascii_lowercase();
413 if matches!(
414 lname.as_str(),
415 "p" | "br"
416 | "div"
417 | "li"
418 | "h1"
419 | "h2"
420 | "h3"
421 | "h4"
422 | "h5"
423 | "h6"
424 | "tr"
425 | "section"
426 ) {
427 buf.push('\n');
428 }
429 }
430 }
431 }
432
433 let buf = buf
436 .replace(" ", " ")
437 .replace("&", "&")
438 .replace("<", "<")
439 .replace(">", ">")
440 .replace(""", "\"")
441 .replace("'", "'");
442
443 let mut out = String::with_capacity(buf.len());
445 let mut prev_blank = true;
446 let mut blank_run = 0;
447 for line in buf.lines() {
448 let trimmed = line.trim();
449 if trimmed.is_empty() {
450 blank_run += 1;
451 if blank_run <= 1 && !prev_blank {
452 out.push('\n');
453 }
454 continue;
455 }
456 blank_run = 0;
457 if !prev_blank {
458 out.push('\n');
459 }
460 let mut last_space = false;
462 for c in trimmed.chars() {
463 if c.is_whitespace() {
464 if !last_space {
465 out.push(' ');
466 }
467 last_space = true;
468 } else {
469 out.push(c);
470 last_space = false;
471 }
472 }
473 prev_blank = false;
474 }
475
476 let max_chars = max_bytes / 2; if out.chars().count() > max_chars {
479 out = out.chars().take(max_chars).collect::<String>() + "…";
480 }
481 out
482}
483
484fn extract_title(html: &str) -> Option<String> {
485 let lower = html.to_ascii_lowercase();
486 let start = lower.find("<title")?;
487 let end_open = lower[start..].find('>')?;
488 let body_start = start + end_open + 1;
489 let close = lower[body_start..].find("</title")?;
490 let raw = &html[body_start..body_start + close];
491 let trimmed = raw.trim();
492 if trimmed.is_empty() {
493 None
494 } else {
495 Some(trimmed.chars().take(160).collect())
496 }
497}
498
499fn strip_blocks_by_class_keyword(html: &str, keywords: &[&str]) -> String {
512 let lower = html.to_ascii_lowercase();
513 let mut out = String::with_capacity(html.len());
514 let mut cursor = 0usize;
515
516 while cursor < html.len() {
517 let Some(open_rel) = lower[cursor..].find('<') else {
520 out.push_str(&html[cursor..]);
521 break;
522 };
523 let open_abs = cursor + open_rel;
524 let Some(end_rel) = lower[open_abs..].find('>') else {
525 out.push_str(&html[cursor..]);
526 break;
527 };
528 let tag_end = open_abs + end_rel + 1;
529 let tag_chunk = &lower[open_abs..tag_end];
530
531 if tag_chunk.starts_with("</") {
534 out.push_str(&html[cursor..tag_end]);
535 cursor = tag_end;
536 continue;
537 }
538
539 let after_lt = &tag_chunk[1..];
542 let name_end = after_lt
543 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
544 .unwrap_or(after_lt.len());
545 let tag_name = &after_lt[..name_end];
546 if tag_name.is_empty() {
547 out.push_str(&html[cursor..tag_end]);
548 cursor = tag_end;
549 continue;
550 }
551
552 let mut matched = false;
554 for attr in ["class", "id", "role"] {
555 if let Some(attr_pos) = tag_chunk.find(&format!(" {attr}=")) {
557 let after = &tag_chunk[attr_pos + attr.len() + 2..];
558 let quote = after.chars().next().unwrap_or('"');
559 if quote != '"' && quote != '\'' {
560 continue;
561 }
562 let value_start = 1usize;
563 let value_end = after[value_start..]
564 .find(quote)
565 .map(|p| value_start + p)
566 .unwrap_or(after.len());
567 let value = &after[value_start..value_end];
568 for kw in keywords {
569 if value.contains(kw) {
570 matched = true;
571 break;
572 }
573 }
574 if matched {
575 break;
576 }
577 }
578 }
579
580 if !matched {
581 out.push_str(&html[cursor..tag_end]);
582 cursor = tag_end;
583 continue;
584 }
585
586 out.push_str(&html[cursor..open_abs]);
589 let close_pat = format!("</{tag_name}");
590 let open_pat_nested = format!("<{tag_name}");
591 let mut depth: i32 = 1;
592 let mut scan = tag_end;
593 while scan < html.len() && depth > 0 {
594 let next_close = lower[scan..].find(&close_pat).map(|p| scan + p);
596 let next_open = lower[scan..].find(&open_pat_nested).map(|p| scan + p);
597 match (next_open, next_close) {
598 (Some(o), Some(c)) if o < c => {
599 let open_end = lower[o..]
600 .find('>')
601 .map(|p| o + p + 1)
602 .unwrap_or(html.len());
603 depth += 1;
604 scan = open_end;
605 }
606 (_, Some(c)) => {
607 let close_end = lower[c..]
608 .find('>')
609 .map(|p| c + p + 1)
610 .unwrap_or(html.len());
611 depth -= 1;
612 scan = close_end;
613 }
614 _ => break,
615 }
616 }
617 cursor = scan;
618 }
619
620 out
621}
622
623fn strip_block(html: &str, tag: &str) -> String {
624 let lower = html.to_ascii_lowercase();
625 let open_pat = format!("<{tag}");
626 let close_pat = format!("</{tag}");
627 let mut out = String::with_capacity(html.len());
628 let mut cursor = 0;
629 while cursor < html.len() {
630 let Some(open_rel) = lower[cursor..].find(&open_pat) else {
631 out.push_str(&html[cursor..]);
632 break;
633 };
634 let open_abs = cursor + open_rel;
635 out.push_str(&html[cursor..open_abs]);
636 let after_open = lower[open_abs..].find('>').map(|p| open_abs + p + 1);
637 let Some(after) = after_open else { break };
638 let Some(close_rel) = lower[after..].find(&close_pat) else {
639 break;
640 };
641 let close_abs = after + close_rel;
642 let close_end = lower[close_abs..]
643 .find('>')
644 .map(|p| close_abs + p + 1)
645 .unwrap_or(html.len());
646 cursor = close_end;
647 }
648 out
649}
650
651enum Token<'a> {
652 Text(&'a str),
653 Tag(&'a str),
654}
655
656fn tokenize(html: &str) -> Vec<Token<'_>> {
657 let mut out = Vec::new();
658 let mut cursor = 0;
659 while cursor < html.len() {
660 let Some(open) = html[cursor..].find('<') else {
661 out.push(Token::Text(&html[cursor..]));
662 break;
663 };
664 if open > 0 {
665 out.push(Token::Text(&html[cursor..cursor + open]));
666 }
667 let tag_start = cursor + open + 1;
668 let Some(close) = html[tag_start..].find('>') else {
669 break;
670 };
671 let tag_end = tag_start + close;
672 let tag_slice = &html[tag_start..tag_end];
674 let name_end = tag_slice
675 .find(|c: char| c.is_whitespace())
676 .unwrap_or(tag_slice.len());
677 out.push(Token::Tag(&tag_slice[..name_end]));
678 cursor = tag_end + 1;
679 }
680 out
681}
682
683pub fn render_block(summaries: &[LinkSummary]) -> String {
686 if summaries.is_empty() {
687 return String::new();
688 }
689 let mut out = String::from("# LINK CONTEXT\n\n");
690 out.push_str(
691 "The user's message included the following links. The runtime fetched each one and \
692 extracted a text summary so you can answer with grounded facts. Cite the link if you \
693 use it; do not invent details that aren't in the summary.\n\n",
694 );
695 for (idx, s) in summaries.iter().enumerate() {
696 out.push_str(&format!("## [{}] {}\n", idx + 1, s.url));
697 if let Some(title) = s.title.as_deref() {
698 out.push_str(&format!("Title: {title}\n"));
699 }
700 out.push('\n');
701 out.push_str(&s.body);
702 out.push_str("\n\n");
703 }
704 out
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn detect_picks_https_and_http_in_order_dedup() {
713 let txt = "see https://a.com/x and http://b.com? and https://a.com/x again";
714 let urls = detect_urls(txt, 10);
715 assert_eq!(urls, vec!["https://a.com/x", "http://b.com"]);
716 }
717
718 #[test]
719 fn detect_strips_trailing_punctuation() {
720 let urls = detect_urls("ok https://example.com/foo, bye.", 10);
721 assert_eq!(urls, vec!["https://example.com/foo"]);
722 }
723
724 #[test]
725 fn detect_caps_at_max() {
726 let urls = detect_urls("https://a.com https://b.com https://c.com https://d.com", 2);
727 assert_eq!(urls.len(), 2);
728 assert_eq!(urls[0], "https://a.com");
729 }
730
731 #[test]
732 fn detect_skips_hostile_long_urls() {
733 let huge = format!("https://a.com/{}", "x".repeat(3000));
734 let urls = detect_urls(&format!("look {huge} thanks"), 10);
735 assert!(urls.is_empty(), "URL > 2048 chars must be dropped");
736 }
737
738 #[test]
739 fn host_denylist_blocks_localhost_and_metadata() {
740 let deny = default_deny_hosts();
741 assert!(!host_allowed("http://localhost:8080/x", &deny));
742 assert!(!host_allowed("http://127.0.0.1/", &deny));
743 assert!(!host_allowed("http://metadata.google.internal/x", &deny));
744 assert!(!host_allowed(
745 "http://api.metadata.google.internal/x",
746 &deny
747 ));
748 assert!(host_allowed("https://example.com/x", &deny));
749 }
750
751 #[test]
752 fn extract_strips_scripts_and_styles() {
753 let html = "<html><head><title>T</title></head>\
754 <body><script>alert(1)</script><p>Hello</p>\
755 <style>.x{}</style><p>World</p></body></html>";
756 let out = extract_main_text(html, 4096);
757 assert!(out.contains("Hello"));
758 assert!(out.contains("World"));
759 assert!(!out.contains("alert"));
760 assert!(!out.contains(".x{}"));
761 }
762
763 #[test]
766 fn extract_drops_semantic_boilerplate_tags() {
767 let html = r#"<html><body>
768 <header>SiteName · Login · Cart</header>
769 <nav>Home | Blog | Contact</nav>
770 <main><article>
771 <h1>The Article</h1>
772 <p>Real content lives here.</p>
773 </article></main>
774 <aside>Related links sidebar noise</aside>
775 <footer>Copyright 2026 · privacy · cookies</footer>
776 </body></html>"#;
777 let out = extract_main_text(html, 4096);
778 assert!(out.contains("The Article"));
779 assert!(out.contains("Real content lives here"));
780 assert!(!out.contains("SiteName"), "stripped <header>");
781 assert!(!out.contains("Home | Blog"), "stripped <nav>");
782 assert!(!out.contains("Related links sidebar"), "stripped <aside>");
783 assert!(!out.contains("Copyright"), "stripped <footer>");
784 }
785
786 #[test]
787 fn extract_drops_class_marked_sidebars() {
788 let html = r#"<html><body>
789 <article><p>Article body.</p></article>
790 <div class="sidebar widget">Newsletter signup form</div>
791 <div class="related-articles">More to read</div>
792 <div id="comments-section">User comments here</div>
793 </body></html>"#;
794 let out = extract_main_text(html, 4096);
795 assert!(out.contains("Article body"));
796 assert!(!out.contains("Newsletter signup"));
797 assert!(!out.contains("More to read"));
798 assert!(!out.contains("User comments here"));
799 }
800
801 #[test]
802 fn extract_drops_role_navigation_blocks() {
803 let html = r#"<html><body>
804 <div role="navigation"><a href=/>Home</a></div>
805 <p>Main paragraph.</p>
806 </body></html>"#;
807 let out = extract_main_text(html, 4096);
808 assert!(out.contains("Main paragraph"));
809 assert!(!out.contains("Home"));
810 }
811
812 #[test]
813 fn extract_keeps_class_when_no_keyword_match() {
814 let html = r#"<html><body>
817 <div class="content article-body">The actual article.</div>
818 <div class="byline">By Author</div>
819 </body></html>"#;
820 let out = extract_main_text(html, 4096);
821 assert!(out.contains("The actual article"));
822 assert!(out.contains("By Author"));
823 }
824
825 #[test]
826 fn extract_drops_button_and_form_clutter() {
827 let html = r#"<html><body>
828 <form><input/><button>Subscribe</button></form>
829 <p>Article opener.</p>
830 <button>Share</button>
831 </body></html>"#;
832 let out = extract_main_text(html, 4096);
833 assert!(out.contains("Article opener"));
834 assert!(!out.contains("Subscribe"));
835 assert!(!out.contains("Share"));
836 }
837
838 #[test]
839 fn extract_title_from_head() {
840 let html = "<html><head><title>My Page</title></head><body>x</body></html>";
841 assert_eq!(extract_title(html).as_deref(), Some("My Page"));
842 }
843
844 #[test]
845 fn extract_handles_missing_title() {
846 let html = "<html><body>no title here</body></html>";
847 assert!(extract_title(html).is_none());
848 }
849
850 #[test]
851 fn render_block_lists_summaries() {
852 let s = vec![
853 LinkSummary {
854 url: "https://a.com".into(),
855 title: Some("A".into()),
856 body: "alpha body".into(),
857 },
858 LinkSummary {
859 url: "https://b.com".into(),
860 title: None,
861 body: "bravo body".into(),
862 },
863 ];
864 let out = render_block(&s);
865 assert!(out.contains("# LINK CONTEXT"));
866 assert!(out.contains("[1] https://a.com"));
867 assert!(out.contains("Title: A"));
868 assert!(out.contains("alpha body"));
869 assert!(out.contains("[2] https://b.com"));
870 assert!(out.contains("bravo body"));
871 }
872
873 #[test]
874 fn render_block_empty_yields_empty_string() {
875 assert_eq!(render_block(&[]), "");
876 }
877
878 #[test]
879 fn config_disabled_by_default() {
880 let cfg = LinkUnderstandingConfig::default();
881 assert!(!cfg.enabled);
882 assert_eq!(cfg.max_links_per_turn, 3);
883 assert_eq!(cfg.max_bytes, 256 * 1024);
884 assert!(cfg.deny_hosts.iter().any(|d| d == "localhost"));
885 }
886
887 #[tokio::test]
888 async fn fetch_skips_when_disabled() {
889 let cfg = LinkUnderstandingConfig::default(); let ext = LinkExtractor::new(&cfg);
891 let r = ext.fetch("https://example.com/", &cfg).await;
892 assert!(r.is_none(), "must short-circuit when disabled");
893 }
894
895 #[tokio::test]
896 async fn fetch_skips_denylisted_host() {
897 let cfg = LinkUnderstandingConfig {
898 enabled: true,
899 ..LinkUnderstandingConfig::default()
900 };
901 let ext = LinkExtractor::new(&cfg);
902 let r = ext.fetch("http://localhost:65530/", &cfg).await;
906 assert!(r.is_none());
907 }
908}