Skip to main content

arch_toolkit/news/
article.rs

1//! Safe, bounded HTML-to-text extraction for news article pages.
2
3use crate::error::{ArchToolkitError, Result};
4
5/// Maximum HTML response size accepted for one article extraction.
6pub const MAX_ARTICLE_HTML_BYTES: usize = 512 * 1024;
7/// Maximum text size emitted from one article extraction.
8pub const MAX_ARTICLE_TEXT_BYTES: usize = 256 * 1024;
9/// Maximum raw anchor destination length accepted before URL resolution.
10const MAX_LINK_BYTES: usize = 4 * 1024;
11
12/// What: Extract readable text from a bounded HTML news article.
13///
14/// Inputs:
15/// - `html`: Article HTML, limited to [`MAX_ARTICLE_HTML_BYTES`] bytes.
16/// - `base_url`: Absolute HTTP(S) article URL used to resolve relative links.
17///
18/// Output:
19/// - Plain text with paragraphs, list items, code blocks, and Markdown-safe
20///   HTTP(S) links, or an explicit validation/size error.
21///
22/// Details:
23/// - Script, style, template, and noscript content is discarded rather than
24///   interpreted. HTML is never executed.
25/// - Relative links are resolved against `base_url`; non-HTTP(S) links remain
26///   readable text without a destination.
27/// - This intentionally small extractor is not a general browser or HTML
28///   sanitizer. It preserves the article structures needed by news callers
29///   while enforcing input and output bounds.
30///
31/// # Errors
32///
33/// Returns `ArchToolkitError::InputTooLong` when the input or output exceeds
34/// its bound, and `ArchToolkitError::InvalidInput` for an invalid base URL.
35pub fn extract_article_text(html: &str, base_url: &str) -> Result<String> {
36    ensure_article_input_bound(html)?;
37    let base = parse_article_base_url(base_url)?;
38    let mut extractor = ArticleTextExtractor::new();
39    scan_article_html(html, &base, &mut extractor)?;
40    extractor.finish()
41}
42
43/// What: Fetch one article through a caller-provided client and extract its text.
44///
45/// Inputs:
46/// - `client`: Caller-configured reqwest client controlling transport policy.
47/// - `article_url`: Absolute HTTP(S) article URL to fetch and use as link base.
48///
49/// Output:
50/// - Extracted bounded article text or a transport, status, validation, or
51///   extraction error.
52///
53/// Details:
54/// - The caller owns timeouts, redirects, proxy policy, and fetch cadence.
55/// - The response body is read incrementally and rejected above
56///   [`MAX_ARTICLE_HTML_BYTES`].
57///
58/// # Errors
59///
60/// Returns an error for invalid URLs, failed requests, non-success responses,
61/// oversized bodies, invalid UTF-8, or extraction failures.
62pub async fn fetch_article_text(client: &reqwest::Client, article_url: &str) -> Result<String> {
63    let html =
64        fetch_bounded_text(client, article_url, MAX_ARTICLE_HTML_BYTES, "news article").await?;
65    extract_article_text(&html, article_url)
66}
67
68/// What: Read a successful HTTP response into a string without exceeding a byte bound.
69///
70/// Inputs:
71/// - `client`: Caller-configured reqwest client.
72/// - `url`: HTTP(S) URL to request.
73/// - `maximum_bytes`: Inclusive response-size bound.
74/// - `resource_name`: Human-readable resource label for errors.
75///
76/// Output:
77/// - UTF-8 response text no larger than `maximum_bytes`.
78///
79/// Details:
80/// - Checks both Content-Length when provided and streamed chunks, so omitted
81///   or misleading headers cannot bypass the bound.
82/// - Shared by feed and article fetchers without depending on AUR internals.
83///
84/// # Errors
85///
86/// Returns an error for zero bounds, request failures, non-success statuses,
87/// oversized responses, or invalid UTF-8.
88pub(super) async fn fetch_bounded_text(
89    client: &reqwest::Client,
90    url: &str,
91    maximum_bytes: usize,
92    resource_name: &str,
93) -> Result<String> {
94    if maximum_bytes == 0 {
95        return Err(ArchToolkitError::InvalidInput(format!(
96            "{resource_name} response bound must be greater than zero"
97        )));
98    }
99    let parsed_url = parse_http_url(url, resource_name)?;
100    let mut response = client.get(parsed_url).send().await.map_err(|error| {
101        ArchToolkitError::Parse(format!("{resource_name} request failed: {error}"))
102    })?;
103    let status = response.status();
104    if !status.is_success() {
105        return Err(ArchToolkitError::Parse(format!(
106            "{resource_name} returned status {status}"
107        )));
108    }
109
110    let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
111        ArchToolkitError::InvalidInput(format!("{resource_name} response bound is too large"))
112    })?;
113    if response
114        .content_length()
115        .is_some_and(|length| length > maximum_length)
116    {
117        return Err(response_too_large_error(resource_name, maximum_bytes));
118    }
119
120    let mut bytes = Vec::new();
121    while let Some(chunk) = response.chunk().await.map_err(|error| {
122        ArchToolkitError::Parse(format!("{resource_name} response read failed: {error}"))
123    })? {
124        if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
125            return Err(response_too_large_error(resource_name, maximum_bytes));
126        }
127        bytes.extend_from_slice(&chunk);
128    }
129    String::from_utf8(bytes).map_err(|error| {
130        ArchToolkitError::Parse(format!(
131            "{resource_name} response was not valid UTF-8: {error}"
132        ))
133    })
134}
135
136/// What: Validate an absolute article base URL.
137///
138/// Inputs:
139/// - `base_url`: Candidate absolute URL supplied by the caller.
140///
141/// Output:
142/// - Parsed HTTP(S) URL suitable for relative-link resolution.
143///
144/// Details:
145/// - Rejects non-HTTP(S) schemes before any extraction output is generated.
146fn parse_article_base_url(base_url: &str) -> Result<reqwest::Url> {
147    parse_http_url(base_url, "article base URL")
148}
149
150/// What: Validate an HTTP(S) URL for a bounded caller-owned request.
151///
152/// Inputs:
153/// - `url`: Candidate URL to parse.
154/// - `resource_name`: Resource label used in errors.
155///
156/// Output:
157/// - Parsed URL restricted to `http` or `https`.
158///
159/// Details:
160/// - Does not make a request; caller-client transport policy remains in the
161///   supplied reqwest client.
162fn parse_http_url(url: &str, resource_name: &str) -> Result<reqwest::Url> {
163    let parsed = reqwest::Url::parse(url).map_err(|error| {
164        ArchToolkitError::InvalidInput(format!("invalid {resource_name} URL: {error}"))
165    })?;
166    if matches!(parsed.scheme(), "http" | "https") {
167        return Ok(parsed);
168    }
169    Err(ArchToolkitError::InvalidInput(format!(
170        "{resource_name} URL must use http or https"
171    )))
172}
173
174/// What: Build a consistent explicit error for response-bound violations.
175///
176/// Inputs:
177/// - `resource_name`: Resource label shown to callers.
178/// - `maximum_bytes`: Configured maximum body size.
179///
180/// Output:
181/// - `ArchToolkitError::InputTooLong` with the known safety bound.
182///
183/// Details:
184/// - The actual streamed size is intentionally not reported because it may be
185///   incomplete when the body is rejected mid-stream.
186fn response_too_large_error(resource_name: &str, maximum_bytes: usize) -> ArchToolkitError {
187    ArchToolkitError::InputTooLong {
188        field: format!("{resource_name} response"),
189        max_length: maximum_bytes,
190        actual_length: maximum_bytes.saturating_add(1),
191    }
192}
193
194/// What: Reject article HTML larger than the extractor's fixed bound.
195///
196/// Inputs:
197/// - `html`: Candidate article HTML.
198///
199/// Output:
200/// - `Ok(())` within the bound, otherwise `InputTooLong`.
201///
202/// Details:
203/// - This check occurs before parsing to bound scanner work and allocations.
204fn ensure_article_input_bound(html: &str) -> Result<()> {
205    if html.len() <= MAX_ARTICLE_HTML_BYTES {
206        return Ok(());
207    }
208    Err(ArchToolkitError::InputTooLong {
209        field: "article HTML".to_string(),
210        max_length: MAX_ARTICLE_HTML_BYTES,
211        actual_length: html.len(),
212    })
213}
214
215/// What: Scan article HTML and dispatch text/tag tokens to an extractor.
216///
217/// Inputs:
218/// - `html`: Previously bounded article markup.
219/// - `base_url`: Valid HTTP(S) URL for relative links.
220/// - `extractor`: Mutable extraction state.
221///
222/// Output:
223/// - `Ok(())` after all complete tokens are processed.
224///
225/// Details:
226/// - Unterminated tags are treated as literal text, which avoids silently
227///   dropping visible content from malformed pages.
228fn scan_article_html(
229    html: &str,
230    base_url: &reqwest::Url,
231    extractor: &mut ArticleTextExtractor,
232) -> Result<()> {
233    let mut remaining = html;
234    while let Some(start) = remaining.find('<') {
235        extractor.append_text(&remaining[..start])?;
236        remaining = &remaining[start..];
237        if let Some(after_comment) = skip_html_comment(remaining) {
238            remaining = after_comment;
239            continue;
240        }
241        let Some(end) = find_tag_end(remaining) else {
242            extractor.append_text(remaining)?;
243            return Ok(());
244        };
245        extractor.handle_tag(&remaining[1..end], base_url)?;
246        remaining = &remaining[end + 1..];
247    }
248    extractor.append_text(remaining)
249}
250
251/// What: Skip one complete HTML comment when the input begins with one.
252///
253/// Inputs:
254/// - `input`: Remaining HTML beginning at a possible `<` token.
255///
256/// Output:
257/// - Remaining input after a complete comment, or `None` for non-comments and
258///   unterminated comments.
259///
260/// Details:
261/// - Comment text is never emitted into article output.
262fn skip_html_comment(input: &str) -> Option<&str> {
263    let suffix = input.strip_prefix("<!--")?;
264    let end = suffix.find("-->")?;
265    Some(&suffix[end + 3..])
266}
267
268/// What: Locate the closing `>` of one HTML tag while respecting quotes.
269///
270/// Inputs:
271/// - `input`: Remaining HTML that starts with `<`.
272///
273/// Output:
274/// - Byte index of the closing `>`, or `None` for an unterminated tag.
275///
276/// Details:
277/// - Quoted attribute values may contain `>` and must not terminate a tag.
278fn find_tag_end(input: &str) -> Option<usize> {
279    let mut quote = None;
280    for (index, character) in input.char_indices().skip(1) {
281        match (quote, character) {
282            (None, '\'' | '"') => quote = Some(character),
283            (Some(active), current) if active == current => quote = None,
284            (None, '>') => return Some(index),
285            _ => {}
286        }
287    }
288    None
289}
290
291/// What: Hold one open link's output position and resolved destination.
292///
293/// Inputs:
294/// - Created when an opening anchor tag is encountered.
295///
296/// Output:
297/// - Allows the closing tag to append a safe destination or discard an empty link.
298///
299/// Details:
300/// - Invalid or non-HTTP(S) destinations use `None` and preserve label text.
301struct OpenLink {
302    /// Position of the opening `[` in the output buffer.
303    output_start: usize,
304    /// Resolved and Markdown-escaped HTTP(S) destination when valid.
305    destination: Option<String>,
306}
307
308/// What: Maintain bounded structural state while scanning article HTML.
309///
310/// Inputs:
311/// - Constructed internally by [`extract_article_text`].
312///
313/// Output:
314/// - A plain-text buffer with preserved supported article structures.
315///
316/// Details:
317/// - Suppressed-tag, preformatted, inline-code, and link stacks are independent
318///   so malformed nested tags cannot execute or alter unrelated parser state.
319struct ArticleTextExtractor {
320    /// Accumulated extracted text.
321    output: String,
322    /// Nested tags whose text must be discarded.
323    suppressed_tags: Vec<String>,
324    /// Nesting depth for preformatted code blocks.
325    pre_depth: usize,
326    /// Nesting depth for inline code tags outside preformatted blocks.
327    inline_code_depth: usize,
328    /// Open anchors awaiting their closing tag.
329    open_links: Vec<OpenLink>,
330}
331
332impl ArticleTextExtractor {
333    /// What: Create empty extraction state.
334    ///
335    /// Inputs: None.
336    ///
337    /// Output:
338    /// - Fresh parser state with no visible text or open structures.
339    ///
340    /// Details:
341    /// - All state remains local to one extraction call.
342    const fn new() -> Self {
343        Self {
344            output: String::new(),
345            suppressed_tags: Vec::new(),
346            pre_depth: 0,
347            inline_code_depth: 0,
348            open_links: Vec::new(),
349        }
350    }
351
352    /// What: Append one visible text token using context-appropriate whitespace.
353    ///
354    /// Inputs:
355    /// - `text`: Raw HTML text token outside tag delimiters.
356    ///
357    /// Output:
358    /// - Updates the output buffer or reports a text-size violation.
359    ///
360    /// Details:
361    /// - Preformatted sections preserve whitespace; ordinary text collapses it.
362    /// - Text inside suppressed elements is ignored.
363    fn append_text(&mut self, text: &str) -> Result<()> {
364        if !self.suppressed_tags.is_empty() || text.is_empty() {
365            return Ok(());
366        }
367        let decoded = decode_html_entities(text);
368        if self.pre_depth > 0 {
369            self.push_visible(&decoded);
370            return self.ensure_output_bound();
371        }
372        for word in decoded.split_whitespace() {
373            if self.needs_word_separator(word) {
374                self.push_visible(" ");
375            }
376            self.push_visible(word);
377        }
378        self.ensure_output_bound()
379    }
380
381    /// What: Handle one HTML tag and update supported structural state.
382    ///
383    /// Inputs:
384    /// - `raw_tag`: Tag content without surrounding `<` and `>`.
385    /// - `base_url`: Valid URL used to resolve anchor destinations.
386    ///
387    /// Output:
388    /// - Updates extraction state or reports a text-size violation.
389    ///
390    /// Details:
391    /// - Unknown tags are ignored while their text remains visible.
392    /// - Script-like elements are suppressed before their content is observed.
393    fn handle_tag(&mut self, raw_tag: &str, base_url: &reqwest::Url) -> Result<()> {
394        let Some((tag_name, attributes, closing, self_closing)) = parse_tag(raw_tag) else {
395            return Ok(());
396        };
397        if self.handle_suppressed_tag(&tag_name, closing, self_closing) {
398            return Ok(());
399        }
400        if closing {
401            self.close_tag(&tag_name)?;
402        } else {
403            self.open_tag(&tag_name, attributes, base_url)?;
404            if self_closing {
405                self.close_tag(&tag_name)?;
406            }
407        }
408        self.ensure_output_bound()
409    }
410
411    /// What: Suppress script-like tag content and consume matching close tags.
412    ///
413    /// Inputs:
414    /// - `tag_name`: Normalized HTML tag name.
415    /// - `closing`: Whether this is a closing tag.
416    /// - `self_closing`: Whether this is a self-closing tag.
417    ///
418    /// Output:
419    /// - `true` when the caller should not process the tag further.
420    ///
421    /// Details:
422    /// - Nested suppressed tags are tracked by name, avoiding accidental exit
423    ///   when malformed markup contains unrelated closing tags.
424    fn handle_suppressed_tag(&mut self, tag_name: &str, closing: bool, self_closing: bool) -> bool {
425        if let Some(open_tag) = self.suppressed_tags.last() {
426            if closing && open_tag == tag_name {
427                let _ = self.suppressed_tags.pop();
428            }
429            return true;
430        }
431        if !closing && !self_closing && is_suppressed_tag(tag_name) {
432            self.suppressed_tags.push(tag_name.to_string());
433            return true;
434        }
435        false
436    }
437
438    /// What: Process an opening supported HTML tag.
439    ///
440    /// Inputs:
441    /// - `tag_name`: Normalized HTML tag name.
442    /// - `attributes`: Raw tag attributes.
443    /// - `base_url`: URL used to resolve an optional anchor destination.
444    ///
445    /// Output:
446    /// - Updates visible structural output and parser stacks.
447    ///
448    /// Details:
449    /// - Paragraph-like tags delimit blocks, list items gain a `- ` marker,
450    ///   and code tags produce Markdown-safe code delimiters.
451    fn open_tag(
452        &mut self,
453        tag_name: &str,
454        attributes: &str,
455        base_url: &reqwest::Url,
456    ) -> Result<()> {
457        match tag_name {
458            "br" => self.ensure_line_breaks(1),
459            "li" => {
460                self.ensure_line_breaks(1);
461                self.push_visible("- ");
462            }
463            "pre" => {
464                self.ensure_line_breaks(2);
465                self.push_visible("```\n");
466                self.pre_depth += 1;
467            }
468            "code" if self.pre_depth == 0 => {
469                if self.needs_word_separator("code") {
470                    self.push_visible(" ");
471                }
472                self.push_visible("`");
473                self.inline_code_depth += 1;
474            }
475            "a" => self.open_link(attributes, base_url),
476            _ if is_block_tag(tag_name) => self.ensure_line_breaks(2),
477            _ => {}
478        }
479        self.ensure_output_bound()
480    }
481
482    /// What: Process a closing supported HTML tag.
483    ///
484    /// Inputs:
485    /// - `tag_name`: Normalized HTML tag name.
486    ///
487    /// Output:
488    /// - Updates visible structural output and parser stacks.
489    ///
490    /// Details:
491    /// - Unmatched close tags are harmless, making extraction resilient to
492    ///   partially malformed article markup.
493    fn close_tag(&mut self, tag_name: &str) -> Result<()> {
494        match tag_name {
495            "li" => self.ensure_line_breaks(1),
496            "pre" if self.pre_depth > 0 => {
497                self.pre_depth -= 1;
498                if self.pre_depth == 0 {
499                    self.ensure_line_breaks(1);
500                    self.push_visible("```\n\n");
501                }
502            }
503            "code" if self.pre_depth == 0 && self.inline_code_depth > 0 => {
504                self.inline_code_depth -= 1;
505                self.push_visible("`");
506            }
507            "a" => self.close_link(),
508            _ if is_block_tag(tag_name) => self.ensure_line_breaks(2),
509            _ => {}
510        }
511        self.ensure_output_bound()
512    }
513
514    /// What: Open an anchor while preserving its visible label text.
515    ///
516    /// Inputs:
517    /// - `attributes`: Raw anchor attributes.
518    /// - `base_url`: Valid URL used to resolve a relative `href`.
519    ///
520    /// Output:
521    /// - Pushes an open-link state for a later closing anchor tag.
522    ///
523    /// Details:
524    /// - Only HTTP(S) destinations are retained, and invalid destinations do
525    ///   not cause label text to be dropped.
526    fn open_link(&mut self, attributes: &str, base_url: &reqwest::Url) {
527        let destination =
528            attribute_value(attributes, "href").and_then(|href| resolve_http_link(base_url, &href));
529        if destination.is_some() && self.needs_word_separator("link") {
530            self.push_visible(" ");
531        }
532        let output_start = self.output.len();
533        if destination.is_some() {
534            self.push_visible("[");
535        }
536        self.open_links.push(OpenLink {
537            output_start,
538            destination,
539        });
540    }
541
542    /// What: Close the most recent anchor and append a safe destination.
543    ///
544    /// Inputs: None.
545    ///
546    /// Output:
547    /// - A Markdown-safe `](url)` suffix, or unchanged visible label text.
548    ///
549    /// Details:
550    /// - Empty links remove their opening bracket rather than emitting `[]()`.
551    fn close_link(&mut self) {
552        let Some(open_link) = self.open_links.pop() else {
553            return;
554        };
555        let Some(destination) = open_link.destination else {
556            return;
557        };
558        if self.output.len() == open_link.output_start + 1 {
559            self.output.truncate(open_link.output_start);
560            return;
561        }
562        self.output.push_str("](");
563        self.output.push_str(&destination);
564        self.output.push(')');
565    }
566
567    /// What: Append one visible text fragment, escaping anchor labels when needed.
568    ///
569    /// Inputs:
570    /// - `text`: Already normalized visible text fragment.
571    ///
572    /// Output:
573    /// - Appends directly to the extractor output buffer.
574    ///
575    /// Details:
576    /// - Escaping `[`/`]`/`\\` inside live anchor labels prevents HTML text from
577    ///   breaking the generated Markdown link structure.
578    fn push_visible(&mut self, text: &str) {
579        if self
580            .open_links
581            .last()
582            .is_some_and(|link| link.destination.is_some())
583        {
584            for character in text.chars() {
585                if matches!(character, '[' | ']' | '\\') {
586                    self.output.push('\\');
587                }
588                self.output.push(character);
589            }
590        } else {
591            self.output.push_str(text);
592        }
593    }
594
595    /// What: Decide whether a normal text word needs a preceding space.
596    ///
597    /// Inputs:
598    /// - `word`: Collapsed non-whitespace text token to append.
599    ///
600    /// Output:
601    /// - `true` when a single separator should be emitted first.
602    ///
603    /// Details:
604    /// - Punctuation following a link or word does not gain a spurious space.
605    fn needs_word_separator(&self, word: &str) -> bool {
606        let Some(previous) = self.output.chars().last() else {
607            return false;
608        };
609        !previous.is_whitespace()
610            && !matches!(previous, '[' | '`')
611            && !word_starts_with_punctuation(word)
612    }
613
614    /// What: Ensure a requested number of trailing line breaks.
615    ///
616    /// Inputs:
617    /// - `count`: Number of line breaks that should end the output.
618    ///
619    /// Output:
620    /// - Appends only the missing line breaks.
621    ///
622    /// Details:
623    /// - Whitespace before a block boundary is removed to keep output stable.
624    fn ensure_line_breaks(&mut self, count: usize) {
625        while self.output.ends_with([' ', '\t']) {
626            let _ = self.output.pop();
627        }
628        let existing = self.output.chars().rev().take_while(|c| *c == '\n').count();
629        for _ in existing..count {
630            self.output.push('\n');
631        }
632    }
633
634    /// What: Enforce the article text output bound.
635    ///
636    /// Inputs: None.
637    ///
638    /// Output:
639    /// - `Ok(())` within the bound or `InputTooLong` when exceeded.
640    ///
641    /// Details:
642    /// - Checked incrementally so malformed markup cannot force an unbounded
643    ///   intermediate output allocation.
644    fn ensure_output_bound(&self) -> Result<()> {
645        if self.output.len() <= MAX_ARTICLE_TEXT_BYTES {
646            return Ok(());
647        }
648        Err(ArchToolkitError::InputTooLong {
649            field: "article text".to_string(),
650            max_length: MAX_ARTICLE_TEXT_BYTES,
651            actual_length: self.output.len(),
652        })
653    }
654
655    /// What: Finalize extracted text after the scanner reaches end of input.
656    ///
657    /// Inputs: None.
658    ///
659    /// Output:
660    /// - Trimmed human-readable article text.
661    ///
662    /// Details:
663    /// - Unterminated structures retain their already-visible label or code
664    ///   content but cannot create executable HTML.
665    fn finish(self) -> Result<String> {
666        self.ensure_output_bound()?;
667        Ok(self.output.trim().to_string())
668    }
669}
670
671/// What: Parse a tag into name, attributes, closing state, and self-closing state.
672///
673/// Inputs:
674/// - `raw_tag`: Tag text excluding `<` and `>` delimiters.
675///
676/// Output:
677/// - Normalized tag metadata, or `None` for declarations and malformed tags.
678///
679/// Details:
680/// - Tag names are restricted to ASCII HTML-style identifier characters to
681///   keep downstream matching simple and deterministic.
682fn parse_tag(raw_tag: &str) -> Option<(String, &str, bool, bool)> {
683    let trimmed = raw_tag.trim();
684    if trimmed.is_empty() || trimmed.starts_with('!') || trimmed.starts_with('?') {
685        return None;
686    }
687    let closing = trimmed.starts_with('/');
688    let without_marker = trimmed.trim_start_matches('/').trim_start();
689    let name_end = without_marker
690        .find(|character: char| !is_tag_name_character(character))
691        .unwrap_or(without_marker.len());
692    if name_end == 0 {
693        return None;
694    }
695    let tag_name = without_marker[..name_end].to_ascii_lowercase();
696    let attributes = &without_marker[name_end..];
697    let self_closing = !closing && attributes.trim_end().ends_with('/');
698    Some((tag_name, attributes, closing, self_closing))
699}
700
701/// What: Decide whether a character is valid in an HTML-style tag or attribute name.
702///
703/// Inputs:
704/// - `character`: Candidate identifier character.
705///
706/// Output:
707/// - `true` for ASCII letters, digits, `-`, `_`, or `:`.
708///
709/// Details:
710/// - Restricting names to these characters avoids treating punctuation in
711///   malformed markup as a supported tag or attribute.
712const fn is_tag_name_character(character: char) -> bool {
713    character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | ':')
714}
715
716/// What: Extract one case-insensitive attribute value from a raw tag suffix.
717///
718/// Inputs:
719/// - `attributes`: Text after an opening tag name.
720/// - `wanted`: Attribute name to find.
721///
722/// Output:
723/// - Decoded attribute value when present, otherwise `None`.
724///
725/// Details:
726/// - Handles single-quoted, double-quoted, and unquoted values without using
727///   a browser parser. Boolean and malformed attributes are ignored.
728fn attribute_value(attributes: &str, wanted: &str) -> Option<String> {
729    let mut remaining = attributes.trim();
730    while !remaining.is_empty() {
731        let name_end = remaining
732            .find(|character: char| !is_tag_name_character(character))
733            .unwrap_or(remaining.len());
734        if name_end == 0 {
735            remaining = &remaining[1..];
736            continue;
737        }
738        let name = &remaining[..name_end];
739        remaining = remaining[name_end..].trim_start();
740        let Some(after_equals) = remaining.strip_prefix('=') else {
741            continue;
742        };
743        remaining = after_equals.trim_start();
744        let (value, after_value) = split_attribute_value(remaining);
745        remaining = after_value.trim_start();
746        if name.eq_ignore_ascii_case(wanted) {
747            return Some(decode_html_entities(value));
748        }
749    }
750    None
751}
752
753/// What: Split the next HTML attribute value from trailing attributes.
754///
755/// Inputs:
756/// - `input`: Text beginning at a quoted or unquoted attribute value.
757///
758/// Output:
759/// - Tuple of value slice and unconsumed suffix.
760///
761/// Details:
762/// - An unterminated quote consumes the rest of the tag as its value, which is
763///   safer than scanning past a malformed delimiter.
764fn split_attribute_value(input: &str) -> (&str, &str) {
765    let Some(first) = input.chars().next() else {
766        return ("", "");
767    };
768    if matches!(first, '\'' | '"') {
769        let quoted = &input[first.len_utf8()..];
770        if let Some(end) = quoted.find(first) {
771            return (&quoted[..end], &quoted[end + first.len_utf8()..]);
772        }
773        return (quoted, "");
774    }
775    let end = input.find(char::is_whitespace).unwrap_or(input.len());
776    (&input[..end], &input[end..])
777}
778
779/// What: Resolve an anchor destination against an article URL and limit schemes.
780///
781/// Inputs:
782/// - `base_url`: Valid article HTTP(S) URL.
783/// - `href`: Decoded raw anchor destination.
784///
785/// Output:
786/// - Escaped absolute HTTP(S) URL, or `None` when invalid/unsupported.
787///
788/// Details:
789/// - Bounds raw link length before URL parsing and rejects schemes such as
790///   `javascript:`, `data:`, and `mailto:`.
791fn resolve_http_link(base_url: &reqwest::Url, href: &str) -> Option<String> {
792    if href.is_empty() || href.len() > MAX_LINK_BYTES {
793        return None;
794    }
795    let resolved = base_url.join(href).ok()?;
796    if !matches!(resolved.scheme(), "http" | "https") {
797        return None;
798    }
799    Some(escape_markdown_destination(resolved.as_str()))
800}
801
802/// What: Escape a URL for use inside a Markdown link destination.
803///
804/// Inputs:
805/// - `url`: Valid absolute HTTP(S) URL.
806///
807/// Output:
808/// - URL with Markdown delimiter characters escaped.
809///
810/// Details:
811/// - Prevents a URL containing parentheses or backslashes from injecting text
812///   outside the generated link destination.
813fn escape_markdown_destination(url: &str) -> String {
814    url.replace('\\', "\\\\")
815        .replace('(', "\\(")
816        .replace(')', "\\)")
817}
818
819/// What: Identify tags whose contents are not article text.
820///
821/// Inputs:
822/// - `tag_name`: Normalized tag name.
823///
824/// Output:
825/// - `true` for tags that should suppress all nested text.
826///
827/// Details:
828/// - These tags commonly contain executable, styling, fallback, or templating
829///   content rather than readable article prose.
830fn is_suppressed_tag(tag_name: &str) -> bool {
831    matches!(tag_name, "script" | "style" | "template" | "noscript")
832}
833
834/// What: Identify HTML tags that form visible text block boundaries.
835///
836/// Inputs:
837/// - `tag_name`: Normalized tag name.
838///
839/// Output:
840/// - `true` for supported paragraph-like block elements.
841///
842/// Details:
843/// - Lists and preformatted blocks have dedicated formatting rules elsewhere.
844fn is_block_tag(tag_name: &str) -> bool {
845    matches!(
846        tag_name,
847        "p" | "article"
848            | "section"
849            | "div"
850            | "header"
851            | "footer"
852            | "main"
853            | "aside"
854            | "blockquote"
855            | "h1"
856            | "h2"
857            | "h3"
858            | "h4"
859            | "h5"
860            | "h6"
861            | "ul"
862            | "ol"
863    )
864}
865
866/// What: Identify tokens that should attach to preceding visible text.
867///
868/// Inputs:
869/// - `word`: Collapsed text token being appended.
870///
871/// Output:
872/// - `true` when its first character is closing punctuation.
873///
874/// Details:
875/// - Prevents `word .` and `](url) .` output from ordinary HTML text.
876fn word_starts_with_punctuation(word: &str) -> bool {
877    word.starts_with(['.', ',', ';', ':', '!', '?', ')', ']', '}'])
878}
879
880/// What: Decode common named and numeric HTML entities in text or attributes.
881///
882/// Inputs:
883/// - `input`: Raw text containing possible `&name;` or `&#...;` entities.
884///
885/// Output:
886/// - Decoded text while preserving unknown or malformed entities literally.
887///
888/// Details:
889/// - Entity decoding happens before whitespace handling and link resolution so
890///   visible text and relative query strings are represented correctly.
891fn decode_html_entities(input: &str) -> String {
892    let mut output = String::with_capacity(input.len());
893    let mut remaining = input;
894    while let Some(start) = remaining.find('&') {
895        output.push_str(&remaining[..start]);
896        let after_ampersand = &remaining[start + 1..];
897        let Some(end) = after_ampersand.find(';') else {
898            output.push('&');
899            output.push_str(after_ampersand);
900            return output;
901        };
902        let entity = &after_ampersand[..end];
903        if let Some(decoded) = decode_entity(entity) {
904            output.push(decoded);
905        } else {
906            output.push('&');
907            output.push_str(entity);
908            output.push(';');
909        }
910        remaining = &after_ampersand[end + 1..];
911    }
912    output.push_str(remaining);
913    output
914}
915
916/// What: Decode one named or numeric HTML entity.
917///
918/// Inputs:
919/// - `entity`: Entity name without leading `&` or trailing `;`.
920///
921/// Output:
922/// - Decoded character when recognized, otherwise `None`.
923///
924/// Details:
925/// - Supports the standard entities used by Arch feeds and numeric Unicode
926///   code points without adding an HTML parser dependency.
927fn decode_entity(entity: &str) -> Option<char> {
928    match entity {
929        "amp" => Some('&'),
930        "lt" => Some('<'),
931        "gt" => Some('>'),
932        "quot" => Some('"'),
933        "apos" | "#39" => Some('\''),
934        "nbsp" => Some(' '),
935        _ => decode_numeric_entity(entity),
936    }
937}
938
939/// What: Decode a numeric decimal or hexadecimal HTML entity.
940///
941/// Inputs:
942/// - `entity`: Numeric entity body without delimiters.
943///
944/// Output:
945/// - Unicode character when the code point is valid, otherwise `None`.
946///
947/// Details:
948/// - Invalid numeric values remain literal in the caller's output.
949fn decode_numeric_entity(entity: &str) -> Option<char> {
950    let hexadecimal = entity
951        .strip_prefix("#x")
952        .or_else(|| entity.strip_prefix("#X"));
953    if let Some(value) = hexadecimal {
954        return u32::from_str_radix(value, 16).ok().and_then(char::from_u32);
955    }
956    entity
957        .strip_prefix('#')
958        .and_then(|value| value.parse::<u32>().ok())
959        .and_then(char::from_u32)
960}
961
962#[cfg(test)]
963mod tests {
964    use super::{MAX_ARTICLE_HTML_BYTES, extract_article_text};
965
966    #[test]
967    /// What: Verify supported article structures become safe readable text.
968    ///
969    /// Inputs:
970    /// - HTML with paragraphs, lists, inline/preformatted code, relative links,
971    ///   entities, and script content.
972    ///
973    /// Output:
974    /// - Text preserves visible structures, resolves the relative link, and
975    ///   discards executable script content.
976    ///
977    /// Details:
978    /// - This is the core fixture proof that the extractor does not execute or
979    ///   emit raw HTML while retaining the requested article structures.
980    fn extracts_supported_article_content_safely() {
981        let html = r#"<article><p>Read <a href="/guide?one=1&amp;two=2">the [guide]</a>.</p>
982<ul><li>First item</li><li>Use <code>--needed</code></li></ul>
983<pre><code>pacman -Syu
984</code></pre><script>alert('ignored')</script></article>"#;
985        let text = extract_article_text(html, "https://archlinux.org/news/update/")
986            .expect("extract article text");
987
988        assert!(
989            text.contains("Read [the \\[guide\\]](https://archlinux.org/guide?one=1&two=2)."),
990            "actual extracted text: {text:?}"
991        );
992        assert!(text.contains("- First item\n- Use `--needed`"));
993        assert!(text.contains("```\npacman -Syu\n```"));
994        assert!(!text.contains("alert"));
995        assert!(!text.contains("<script"));
996    }
997
998    #[test]
999    /// What: Verify unsafe destinations retain labels without generated links.
1000    ///
1001    /// Inputs:
1002    /// - Article HTML containing a JavaScript URL and a malformed relative URL.
1003    ///
1004    /// Output:
1005    /// - Visible labels remain while unsupported destinations are omitted.
1006    ///
1007    /// Details:
1008    /// - Prevents article HTML from turning non-web schemes into active output.
1009    fn rejects_unsafe_link_schemes() {
1010        let html =
1011            r#"<p><a href="javascript:alert(1)">unsafe</a> and <a href="mailto:x@y">mail</a></p>"#;
1012        let text = extract_article_text(html, "https://archlinux.org/news/update/")
1013            .expect("extract article text");
1014
1015        assert_eq!(text, "unsafe and mail");
1016    }
1017
1018    #[test]
1019    /// What: Verify the HTML input safety bound is enforced before parsing.
1020    ///
1021    /// Inputs:
1022    /// - One byte more than the documented article HTML limit.
1023    ///
1024    /// Output:
1025    /// - An explicit size error.
1026    ///
1027    /// Details:
1028    /// - Avoids unbounded work for hostile or unexpectedly large pages.
1029    fn rejects_oversized_article_html() {
1030        let html = "x".repeat(MAX_ARTICLE_HTML_BYTES + 1);
1031        assert!(extract_article_text(&html, "https://archlinux.org/news/update/").is_err());
1032    }
1033}