Skip to main content

babyrite/expand/
github.rs

1//! GitHub Permalink expansion.
2//!
3//! This module provides functionality for parsing GitHub permalink URLs
4//! and fetching raw file content to display as code blocks.
5
6use regex::Regex;
7use serenity::futures::future::join_all;
8use serenity::prelude::TypeMapKey;
9use std::sync::LazyLock;
10
11use super::{ExpandContext, ExpandError, ExpandedContent, LinkExpander};
12use crate::config::BabyriteConfig;
13use crate::utils::language_for_path;
14
15/// TypeMap key for the shared reqwest HTTP client used to fetch raw content.
16pub struct HttpClient;
17
18impl TypeMapKey for HttpClient {
19    type Value = reqwest::Client;
20}
21
22/// Regex pattern for matching GitHub blob URLs.
23///
24/// Captures: owner, repo, git_ref (commit SHA or branch name), path, and optional line range fragment.
25///
26/// Supported patterns:
27/// - `https://github.com/{owner}/{repo}/blob/{ref}/{path}`
28/// - `https://github.com/{owner}/{repo}/blob/{ref}/{path}#L{line}`
29/// - `https://github.com/{owner}/{repo}/blob/{ref}/{path}#L{start}-L{end}`
30///
31/// The `{ref}` can be a commit SHA (e.g., `abcdef1234567`) or a branch/tag name (e.g., `main`, `feature/foo`).
32///
33/// An optional query string (e.g., `?plain=1`) is consumed but discarded — GitHub's blob query
34/// parameters control browser rendering only and do not affect the raw content served by
35/// `raw.githubusercontent.com`.
36static GITHUB_PERMALINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
37    Regex::new(
38        r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/([^#\s?]+)(?:\?[^#\s]*)?(?:#L(\d+)(?:-L(\d+))?)?",
39    )
40    .unwrap()
41});
42
43/// Maximum number of body bytes read from a raw response.
44///
45/// Why not `Content-Length`: `raw.githubusercontent.com` may respond with chunked
46/// transfer encoding, where the header is absent and any pre-check against it would
47/// pass unconditionally (#625). The limit is enforced on bytes actually received.
48const MAX_BODY_BYTES: usize = 1_048_576;
49
50/// A parsed GitHub permalink.
51#[derive(Debug)]
52pub struct GitHubPermalink {
53    /// Repository owner.
54    pub owner: String,
55    /// Repository name.
56    pub repo: String,
57    /// Git ref (commit SHA or branch/tag name).
58    pub git_ref: String,
59    /// File path within the repository.
60    pub path: String,
61    /// Optional line range specification.
62    pub line_range: Option<LineRange>,
63}
64
65/// A line range extracted from a GitHub permalink fragment.
66#[derive(Debug, Clone, Copy)]
67pub struct LineRange {
68    /// Start line (1-indexed).
69    pub start: usize,
70    /// End line (1-indexed, inclusive). Same as `start` for single-line references.
71    pub end: usize,
72}
73
74/// GitHub permalink expander.
75pub struct GitHubExpander;
76
77#[serenity::async_trait]
78impl LinkExpander for GitHubExpander {
79    fn enabled(&self, config: &BabyriteConfig) -> bool {
80        config.features.github_permalink
81    }
82
83    /// Expands GitHub permalinks into code blocks.
84    #[cfg_attr(coverage_nightly, coverage(off))]
85    async fn expand_all(&self, cx: &ExpandContext<'_>) -> Vec<ExpandedContent> {
86        let permalinks = GitHubPermalink::parse_all(&cx.message.content);
87        if permalinks.is_empty() {
88            return Vec::new();
89        }
90        tracing::debug!(count = permalinks.len(), "parsed GitHub permalinks");
91
92        // `reqwest::Client` is internally reference-counted, so clone it out of
93        // the TypeMap instead of holding the read guard across the fetches below.
94        let http_client = {
95            let data = cx.ctx.data.read().await;
96            data.get::<HttpClient>().cloned()
97        };
98        let Some(http_client) = http_client else {
99            tracing::error!("HTTP client not found in TypeMap");
100            return Vec::new();
101        };
102
103        join_all(permalinks.iter().map(|p| p.fetch(&http_client)))
104            .await
105            .into_iter()
106            .filter_map(|result| match result {
107                Ok(content) => Some(content),
108                Err(e) => {
109                    tracing::error!(error = %e, "failed to expand GitHub permalink");
110                    None
111                }
112            })
113            .collect()
114    }
115}
116
117/// Errors that can occur when expanding a GitHub permalink.
118#[derive(thiserror::Error, Debug)]
119pub enum GitHubExpandError {
120    /// Failed to fetch the raw file content.
121    #[error("Failed to fetch raw content: {0}")]
122    Fetch(String),
123    /// The fetched content exceeds the maximum allowed size.
124    #[error("Content exceeds size limit")]
125    ContentTooLarge,
126    /// An HTTP error occurred.
127    #[error(transparent)]
128    Http(#[from] reqwest::Error),
129}
130
131impl GitHubPermalink {
132    /// Parses all GitHub permalink URLs from the given text.
133    ///
134    /// Matches URLs with commit SHAs, branch names, or tag names.
135    /// The shared link policy applies (see [`super::parse_links`]): angle-bracket
136    /// wrapped and duplicate URLs are ignored, and at most 3 links are returned.
137    pub fn parse_all(text: &str) -> Vec<GitHubPermalink> {
138        super::parse_links(text, &GITHUB_PERMALINK_REGEX, |captures| {
139            let line_range = match captures.get(5) {
140                Some(start) => {
141                    let start = start.as_str().parse().ok()?;
142                    // A missing end (e.g. `#L42`) means a single-line reference.
143                    let end = match captures.get(6) {
144                        Some(end) => end.as_str().parse().ok()?,
145                        None => start,
146                    };
147                    Some(LineRange { start, end })
148                }
149                None => None,
150            };
151
152            Some(GitHubPermalink {
153                owner: captures.get(1)?.as_str().to_string(),
154                repo: captures.get(2)?.as_str().to_string(),
155                git_ref: captures.get(3)?.as_str().to_string(),
156                path: captures.get(4)?.as_str().to_string(),
157                line_range,
158            })
159        })
160    }
161
162    /// Fetches the raw file content from GitHub and returns a code block.
163    #[cfg_attr(coverage_nightly, coverage(off))]
164    #[tracing::instrument(
165        skip(self, http_client),
166        fields(
167            owner = %self.owner,
168            repo = %self.repo,
169            git_ref = %self.git_ref,
170            path = %self.path,
171        )
172    )]
173    pub async fn fetch(
174        &self,
175        http_client: &reqwest::Client,
176    ) -> Result<ExpandedContent, ExpandError> {
177        let config = BabyriteConfig::get();
178        let max_lines = config.github.max_lines;
179
180        let raw_url = format!(
181            "https://raw.githubusercontent.com/{}/{}/{}/{}",
182            self.owner, self.repo, self.git_ref, self.path
183        );
184
185        tracing::debug!(url = %raw_url, "fetching raw content");
186        let started = std::time::Instant::now();
187        let response = http_client
188            .get(&raw_url)
189            .send()
190            .await
191            .map_err(GitHubExpandError::Http)?;
192
193        tracing::debug!(
194            status = %response.status(),
195            content_length = response.content_length(),
196            elapsed_ms = started.elapsed().as_millis(),
197            "received response"
198        );
199
200        if !response.status().is_success() {
201            tracing::warn!(status = %response.status(), "non-success status fetching raw content");
202            return Err(GitHubExpandError::Fetch(format!(
203                "HTTP {} for {}",
204                response.status(),
205                raw_url
206            ))
207            .into());
208        }
209
210        let needed_lines = self.needed_lines(max_lines);
211        let body = read_body_limited(response, needed_lines)
212            .await
213            .inspect_err(|e| tracing::warn!(error = %e, needed_lines, "failed to read body"))?;
214        tracing::debug!(
215            bytes = body.len(),
216            elapsed_ms = started.elapsed().as_millis(),
217            "body read"
218        );
219
220        let content = self.build_code_block(&body, max_lines);
221        tracing::debug!("code block built");
222        Ok(content)
223    }
224
225    /// Number of leading lines [`Self::build_code_block`] can consume.
226    ///
227    /// One line beyond the display limit is required: [`truncate_lines`] tells
228    /// "exactly at the limit" apart from "truncated" by whether a further line exists.
229    fn needed_lines(&self, max_lines: usize) -> usize {
230        match self.line_range {
231            Some(range) => range.end.min(
232                range
233                    .start
234                    .saturating_sub(1)
235                    .saturating_add(max_lines)
236                    .saturating_add(1),
237            ),
238            None => max_lines.saturating_add(1),
239        }
240    }
241
242    /// Builds an `ExpandedContent::CodeBlock` from raw file content.
243    fn build_code_block(&self, body: &str, max_lines: usize) -> ExpandedContent {
244        let all_lines: Vec<&str> = body.lines().collect();
245        let (code, line_info) = match self.line_range {
246            Some(range) => {
247                let start = range.start.saturating_sub(1); // 0-indexed
248                let end = range.end.min(all_lines.len());
249                let selected = all_lines.get(start..end).unwrap_or_default();
250
251                let (code, truncated) = truncate_lines(selected, max_lines);
252                let info = if truncated {
253                    format!(
254                        "L{}-L{}, truncated to {} lines",
255                        range.start, range.end, max_lines
256                    )
257                } else {
258                    format!("L{}-L{}", range.start, range.end)
259                };
260                (code, info)
261            }
262            None => {
263                let (code, truncated) = truncate_lines(&all_lines, max_lines);
264                let info = if truncated {
265                    format!("truncated to {} lines", max_lines)
266                } else {
267                    String::new()
268                };
269                (code, info)
270            }
271        };
272
273        let display_ref = shorten_ref(&self.git_ref);
274        let language = language_for_path(&self.path);
275
276        let line_part = if line_info.is_empty() {
277            String::new()
278        } else {
279            format!(" ({line_info})")
280        };
281        let metadata = format!(
282            "`{}`{} - {}/{}@{}",
283            self.path, line_part, self.owner, self.repo, display_ref
284        );
285
286        ExpandedContent::CodeBlock {
287            language: language.to_string(),
288            code,
289            metadata,
290        }
291    }
292}
293
294/// Reads the response body chunk by chunk, stopping as soon as `needed_lines`
295/// complete lines have been received.
296///
297/// Dropping `response` before the transfer finishes aborts it, so the bytes past
298/// the last displayed line are never downloaded.
299#[cfg_attr(coverage_nightly, coverage(off))]
300async fn read_body_limited(
301    mut response: reqwest::Response,
302    needed_lines: usize,
303) -> Result<String, GitHubExpandError> {
304    let mut body = LimitedBody::new(needed_lines);
305    while !body.is_complete() {
306        let Some(chunk) = response.chunk().await.map_err(GitHubExpandError::Http)? else {
307            break;
308        };
309        body.push(&chunk)?;
310    }
311    Ok(body.finish())
312}
313
314/// Accumulates response chunks until enough lines are received or [`MAX_BODY_BYTES`]
315/// is exceeded.
316struct LimitedBody {
317    buf: Vec<u8>,
318    newlines: usize,
319    needed_lines: usize,
320}
321
322impl LimitedBody {
323    fn new(needed_lines: usize) -> Self {
324        Self {
325            buf: Vec::new(),
326            newlines: 0,
327            needed_lines,
328        }
329    }
330
331    /// Whether enough lines have been received to build the code block.
332    fn is_complete(&self) -> bool {
333        self.newlines >= self.needed_lines
334    }
335
336    /// Appends the part of `chunk` that is still needed, up to [`MAX_BODY_BYTES`].
337    fn push(&mut self, chunk: &[u8]) -> Result<(), GitHubExpandError> {
338        if self.is_complete() {
339            return Ok(());
340        }
341        let wanted = self.needed_lines - self.newlines;
342        let mut newlines = 0;
343        let mut end = chunk.len();
344        for (i, byte) in chunk.iter().enumerate() {
345            if *byte == b'\n' {
346                newlines += 1;
347                if newlines == wanted {
348                    end = i + 1;
349                    break;
350                }
351            }
352        }
353
354        // The limit is checked against the retained slice, not the whole chunk: a
355        // chunk that overshoots the limit only past the last needed line is fine.
356        if self.buf.len() + end > MAX_BODY_BYTES {
357            return Err(GitHubExpandError::ContentTooLarge);
358        }
359
360        self.buf.extend_from_slice(&chunk[..end]);
361        self.newlines += newlines;
362        Ok(())
363    }
364
365    /// Decodes the accumulated bytes.
366    ///
367    /// Why not decode per chunk: a multi-byte sequence can straddle a chunk boundary,
368    /// so decoding happens once over the joined bytes.
369    ///
370    /// Why `encoding_rs` rather than `String::from_utf8_lossy`: this is the decode
371    /// the replaced `Response::text` performed, so BOM sniffing keeps its behaviour —
372    /// the BOM is dropped instead of showing up as an invisible U+FEFF, and a
373    /// UTF-16 BOM selects UTF-16 instead of decoding to mojibake.
374    ///
375    /// A UTF-16 body only survives being read in full: [`Self::push`] counts lines in
376    /// raw bytes, so stopping at the `0A` of a UTF-16LE `\n` (`0A 00`) leaves the
377    /// final code unit incomplete. Counting lines in decoded text instead would mean
378    /// decoding incrementally, which is not worth it for how rare such files are.
379    ///
380    /// Why not read the `Content-Type` charset like `Response::text` does: the header
381    /// is gone by the time chunks are joined. `raw.githubusercontent.com` serves
382    /// `charset=utf-8`, which is also what `text` assumes when the charset is absent.
383    fn finish(self) -> String {
384        encoding_rs::UTF_8.decode(&self.buf).0.into_owned()
385    }
386}
387
388/// Returns true if the given string looks like a commit SHA (4-40 hex characters).
389fn is_commit_sha(s: &str) -> bool {
390    (4..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
391}
392
393/// Shortens a git ref for display. Commit SHAs are truncated to 7 characters;
394/// branch/tag names are returned as-is.
395fn shorten_ref(git_ref: &str) -> &str {
396    if is_commit_sha(git_ref) {
397        &git_ref[..7.min(git_ref.len())]
398    } else {
399        git_ref
400    }
401}
402
403/// Truncates lines to the given maximum, returning the joined string and whether truncation occurred.
404fn truncate_lines(lines: &[&str], max: usize) -> (String, bool) {
405    let kept = &lines[..lines.len().min(max)];
406    (kept.join("\n"), lines.len() > max)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    // --- truncate_lines ---
414
415    #[test]
416    fn truncate_lines_under_limit() {
417        let lines = vec!["a", "b", "c"];
418        let (result, truncated) = truncate_lines(&lines, 5);
419        assert_eq!(result, "a\nb\nc");
420        assert!(!truncated);
421    }
422
423    #[test]
424    fn truncate_lines_at_limit() {
425        let lines = vec!["a", "b", "c"];
426        let (result, truncated) = truncate_lines(&lines, 3);
427        assert_eq!(result, "a\nb\nc");
428        assert!(!truncated);
429    }
430
431    #[test]
432    fn truncate_lines_over_limit() {
433        let lines = vec!["a", "b", "c", "d", "e"];
434        let (result, truncated) = truncate_lines(&lines, 2);
435        assert_eq!(result, "a\nb");
436        assert!(truncated);
437    }
438
439    #[test]
440    fn truncate_lines_empty() {
441        let lines: Vec<&str> = vec![];
442        let (result, truncated) = truncate_lines(&lines, 5);
443        assert_eq!(result, "");
444        assert!(!truncated);
445    }
446
447    // --- GitHubPermalink::parse_all ---
448
449    #[test]
450    fn parse_basic_permalink() {
451        let text = "https://github.com/owner/repo/blob/abcdef1234567890abcdef1234567890abcdef12/src/main.rs";
452        let results = GitHubPermalink::parse_all(text);
453        assert_eq!(results.len(), 1);
454        assert_eq!(results[0].owner, "owner");
455        assert_eq!(results[0].repo, "repo");
456        assert_eq!(
457            results[0].git_ref,
458            "abcdef1234567890abcdef1234567890abcdef12"
459        );
460        assert_eq!(results[0].path, "src/main.rs");
461        assert!(results[0].line_range.is_none());
462    }
463
464    #[test]
465    fn parse_permalink_with_single_line() {
466        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs#L42";
467        let results = GitHubPermalink::parse_all(text);
468        assert_eq!(results.len(), 1);
469        let range = results[0].line_range.unwrap();
470        assert_eq!(range.start, 42);
471        assert_eq!(range.end, 42);
472    }
473
474    #[test]
475    fn parse_permalink_with_line_range() {
476        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs#L10-L20";
477        let results = GitHubPermalink::parse_all(text);
478        assert_eq!(results.len(), 1);
479        let range = results[0].line_range.unwrap();
480        assert_eq!(range.start, 10);
481        assert_eq!(range.end, 20);
482    }
483
484    #[test]
485    fn parse_branch_name() {
486        let text = "https://github.com/owner/repo/blob/main/src/lib.rs";
487        let results = GitHubPermalink::parse_all(text);
488        assert_eq!(results.len(), 1);
489        assert_eq!(results[0].git_ref, "main");
490        assert_eq!(results[0].path, "src/lib.rs");
491    }
492
493    #[test]
494    fn parse_branch_name_with_line_range() {
495        let text = "https://github.com/owner/repo/blob/develop/src/main.rs#L5-L10";
496        let results = GitHubPermalink::parse_all(text);
497        assert_eq!(results.len(), 1);
498        assert_eq!(results[0].git_ref, "develop");
499        let range = results[0].line_range.unwrap();
500        assert_eq!(range.start, 5);
501        assert_eq!(range.end, 10);
502    }
503
504    #[test]
505    fn parse_branch_name_with_single_line() {
506        let text = "https://github.com/owner/repo/blob/main/src/lib.rs#L5";
507        let results = GitHubPermalink::parse_all(text);
508        assert_eq!(results.len(), 1);
509        assert_eq!(results[0].git_ref, "main");
510        let range = results[0].line_range.unwrap();
511        assert_eq!(range.start, 5);
512        assert_eq!(range.end, 5);
513    }
514
515    #[test]
516    fn parse_branch_with_special_characters() {
517        let cases = [
518            (
519                "https://github.com/o/r/blob/release-v1.0/f.rs",
520                "release-v1.0",
521            ),
522            (
523                "https://github.com/o/r/blob/feat_something/f.rs",
524                "feat_something",
525            ),
526            ("https://github.com/o/r/blob/v2.0.0/f.rs", "v2.0.0"),
527        ];
528        for (text, expected_ref) in cases {
529            let results = GitHubPermalink::parse_all(text);
530            assert_eq!(results.len(), 1, "failed for: {text}");
531            assert_eq!(results[0].git_ref, expected_ref);
532        }
533    }
534
535    #[test]
536    fn parse_tag_name() {
537        let text = "https://github.com/owner/repo/blob/v1.0.0/src/main.rs#L1-L10";
538        let results = GitHubPermalink::parse_all(text);
539        assert_eq!(results.len(), 1);
540        assert_eq!(results[0].git_ref, "v1.0.0");
541        let range = results[0].line_range.unwrap();
542        assert_eq!(range.start, 1);
543        assert_eq!(range.end, 10);
544    }
545
546    #[test]
547    fn parse_mixed_sha_and_branch() {
548        let text = "https://github.com/o/r/blob/abcd1234/a.rs \
549                    https://github.com/o/r/blob/main/b.rs";
550        let results = GitHubPermalink::parse_all(text);
551        assert_eq!(results.len(), 2);
552        assert_eq!(results[0].git_ref, "abcd1234");
553        assert_eq!(results[1].git_ref, "main");
554    }
555
556    #[test]
557    fn parse_accepts_short_ref() {
558        // Short refs (e.g., short branch names) should still match
559        let text = "https://github.com/owner/repo/blob/abc/src/lib.rs";
560        let results = GitHubPermalink::parse_all(text);
561        assert_eq!(results.len(), 1);
562        assert_eq!(results[0].git_ref, "abc");
563    }
564
565    #[test]
566    fn parse_deduplicates_urls() {
567        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs \
568                    https://github.com/owner/repo/blob/abcd1234/src/lib.rs";
569        let results = GitHubPermalink::parse_all(text);
570        assert_eq!(results.len(), 1);
571    }
572
573    #[test]
574    fn parse_limits_to_three() {
575        let text = "\
576            https://github.com/o/r/blob/aaaa1111/a.rs \
577            https://github.com/o/r/blob/bbbb2222/b.rs \
578            https://github.com/o/r/blob/cccc3333/c.rs \
579            https://github.com/o/r/blob/dddd4444/d.rs";
580        let results = GitHubPermalink::parse_all(text);
581        assert_eq!(results.len(), 3);
582    }
583
584    #[test]
585    fn parse_multiple_different_urls() {
586        let text = "Check https://github.com/a/b/blob/1111aaaa/x.rs#L1 and \
587                    https://github.com/c/d/blob/2222bbbb/y.py#L5-L10";
588        let results = GitHubPermalink::parse_all(text);
589        assert_eq!(results.len(), 2);
590        assert_eq!(results[0].owner, "a");
591        assert_eq!(results[1].owner, "c");
592        assert_eq!(results[1].path, "y.py");
593    }
594
595    #[test]
596    fn parse_no_match() {
597        let text = "Hello, no links here!";
598        let results = GitHubPermalink::parse_all(text);
599        assert!(results.is_empty());
600    }
601
602    #[test]
603    fn parse_permalink_with_query() {
604        let text = "https://github.com/owner/repo/blob/abcd1234/README.md?plain=1";
605        let results = GitHubPermalink::parse_all(text);
606        assert_eq!(results.len(), 1);
607        assert_eq!(results[0].path, "README.md");
608        assert!(results[0].line_range.is_none());
609    }
610
611    #[test]
612    fn parse_permalink_with_query_and_line_range() {
613        // Regression test for issue #593: the URL reported in the issue.
614        let text = "https://github.com/m1sk9/dotfiles/blob/02962edfa2d9f5e1ed3f9a7cded1055b1a64b03d/private_dot_claude/CLAUDE.md?plain=1#L21-L46";
615        let results = GitHubPermalink::parse_all(text);
616        assert_eq!(results.len(), 1);
617        assert_eq!(results[0].owner, "m1sk9");
618        assert_eq!(results[0].repo, "dotfiles");
619        assert_eq!(
620            results[0].git_ref,
621            "02962edfa2d9f5e1ed3f9a7cded1055b1a64b03d"
622        );
623        assert_eq!(results[0].path, "private_dot_claude/CLAUDE.md");
624        let range = results[0].line_range.unwrap();
625        assert_eq!(range.start, 21);
626        assert_eq!(range.end, 46);
627    }
628
629    #[test]
630    fn parse_permalink_with_query_and_single_line() {
631        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs?plain=1#L10";
632        let results = GitHubPermalink::parse_all(text);
633        assert_eq!(results.len(), 1);
634        assert_eq!(results[0].path, "src/lib.rs");
635        let range = results[0].line_range.unwrap();
636        assert_eq!(range.start, 10);
637        assert_eq!(range.end, 10);
638    }
639
640    #[test]
641    fn parse_permalink_with_multiple_query_params() {
642        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs?foo=bar&baz=qux#L1-L2";
643        let results = GitHubPermalink::parse_all(text);
644        assert_eq!(results.len(), 1);
645        assert_eq!(results[0].path, "src/lib.rs");
646        let range = results[0].line_range.unwrap();
647        assert_eq!(range.start, 1);
648        assert_eq!(range.end, 2);
649    }
650
651    #[test]
652    fn parse_ignores_angle_bracket_link() {
653        let text = "<https://github.com/owner/repo/blob/abcd1234/src/lib.rs#L10-L20>";
654        let results = GitHubPermalink::parse_all(text);
655        assert!(results.is_empty());
656    }
657
658    #[test]
659    fn parse_nested_path() {
660        let text = "https://github.com/owner/repo/blob/abcd1234/src/deeply/nested/path/file.rs";
661        let results = GitHubPermalink::parse_all(text);
662        assert_eq!(results.len(), 1);
663        assert_eq!(results[0].path, "src/deeply/nested/path/file.rs");
664    }
665
666    #[test]
667    fn parse_short_commit_sha() {
668        // 4-character SHA is the minimum
669        let text = "https://github.com/owner/repo/blob/abcd/file.rs";
670        let results = GitHubPermalink::parse_all(text);
671        assert_eq!(results.len(), 1);
672        assert_eq!(results[0].git_ref, "abcd");
673    }
674
675    // --- build_code_block ---
676
677    fn make_permalink(path: &str, line_range: Option<LineRange>) -> GitHubPermalink {
678        GitHubPermalink {
679            owner: "owner".to_string(),
680            repo: "repo".to_string(),
681            git_ref: "abcdef1234567".to_string(),
682            path: path.to_string(),
683            line_range,
684        }
685    }
686
687    #[test]
688    fn build_code_block_full_file() {
689        let permalink = make_permalink("src/main.rs", None);
690        let body = "fn main() {\n    println!(\"hello\");\n}";
691        let result = permalink.build_code_block(body, 50);
692
693        match result {
694            ExpandedContent::CodeBlock {
695                language,
696                code,
697                metadata,
698            } => {
699                assert_eq!(language, "rust");
700                assert_eq!(code, body);
701                assert_eq!(metadata, "`src/main.rs` - owner/repo@abcdef1");
702            }
703            _ => panic!("expected CodeBlock"),
704        }
705    }
706
707    #[test]
708    fn build_code_block_with_line_range() {
709        let permalink = make_permalink("src/lib.rs", Some(LineRange { start: 2, end: 3 }));
710        let body = "line1\nline2\nline3\nline4";
711        let result = permalink.build_code_block(body, 50);
712
713        match result {
714            ExpandedContent::CodeBlock {
715                language,
716                code,
717                metadata,
718            } => {
719                assert_eq!(language, "rust");
720                assert_eq!(code, "line2\nline3");
721                assert!(metadata.contains("L2-L3"));
722            }
723            _ => panic!("expected CodeBlock"),
724        }
725    }
726
727    #[test]
728    fn build_code_block_truncated() {
729        let permalink = make_permalink("app.py", None);
730        let body = "a\nb\nc\nd\ne";
731        let result = permalink.build_code_block(body, 2);
732
733        match result {
734            ExpandedContent::CodeBlock { code, metadata, .. } => {
735                assert_eq!(code, "a\nb");
736                assert!(metadata.contains("truncated to 2 lines"));
737            }
738            _ => panic!("expected CodeBlock"),
739        }
740    }
741
742    #[test]
743    fn build_code_block_line_range_truncated() {
744        let permalink = make_permalink("app.py", Some(LineRange { start: 1, end: 5 }));
745        let body = "a\nb\nc\nd\ne";
746        let result = permalink.build_code_block(body, 3);
747
748        match result {
749            ExpandedContent::CodeBlock { code, metadata, .. } => {
750                assert_eq!(code, "a\nb\nc");
751                assert!(metadata.contains("L1-L5"));
752                assert!(metadata.contains("truncated to 3 lines"));
753            }
754            _ => panic!("expected CodeBlock"),
755        }
756    }
757
758    #[test]
759    fn build_code_block_dockerfile_language() {
760        let permalink = make_permalink("docker/Dockerfile", None);
761        let body = "FROM rust:latest";
762        let result = permalink.build_code_block(body, 50);
763
764        match result {
765            ExpandedContent::CodeBlock { language, .. } => {
766                assert_eq!(language, "dockerfile");
767            }
768            _ => panic!("expected CodeBlock"),
769        }
770    }
771
772    #[test]
773    fn build_code_block_short_commit() {
774        let permalink = GitHubPermalink {
775            owner: "o".to_string(),
776            repo: "r".to_string(),
777            git_ref: "abcd".to_string(),
778            path: "f.rs".to_string(),
779            line_range: None,
780        };
781        let result = permalink.build_code_block("x", 50);
782
783        match result {
784            ExpandedContent::CodeBlock { metadata, .. } => {
785                assert!(metadata.contains("o/r@abcd"));
786            }
787            _ => panic!("expected CodeBlock"),
788        }
789    }
790
791    #[test]
792    fn build_code_block_branch_ref() {
793        let permalink = GitHubPermalink {
794            owner: "o".to_string(),
795            repo: "r".to_string(),
796            git_ref: "main".to_string(),
797            path: "f.rs".to_string(),
798            line_range: None,
799        };
800        let result = permalink.build_code_block("x", 50);
801
802        match result {
803            ExpandedContent::CodeBlock { metadata, .. } => {
804                // Branch names should not be truncated
805                assert!(metadata.contains("o/r@main"));
806            }
807            _ => panic!("expected CodeBlock"),
808        }
809    }
810
811    #[test]
812    fn build_code_block_branch_ref_with_line_range() {
813        let permalink = GitHubPermalink {
814            owner: "o".to_string(),
815            repo: "r".to_string(),
816            git_ref: "develop".to_string(),
817            path: "src/lib.rs".to_string(),
818            line_range: Some(LineRange { start: 3, end: 5 }),
819        };
820        let body = "a\nb\nc\nd\ne\nf";
821        let result = permalink.build_code_block(body, 50);
822
823        match result {
824            ExpandedContent::CodeBlock { code, metadata, .. } => {
825                assert_eq!(code, "c\nd\ne");
826                assert!(metadata.contains("L3-L5"));
827                assert!(metadata.contains("o/r@develop"));
828            }
829            _ => panic!("expected CodeBlock"),
830        }
831    }
832
833    // --- GitHubPermalink::needed_lines ---
834
835    #[test]
836    fn needed_lines_without_range_is_max_plus_one() {
837        let permalink = make_permalink("f.rs", None);
838        assert_eq!(permalink.needed_lines(50), 51);
839    }
840
841    #[test]
842    fn needed_lines_with_range_capped_by_range_end() {
843        let permalink = make_permalink("f.rs", Some(LineRange { start: 3, end: 5 }));
844        assert_eq!(permalink.needed_lines(50), 5);
845    }
846
847    #[test]
848    fn needed_lines_with_range_capped_by_max_lines() {
849        let permalink = make_permalink(
850            "f.rs",
851            Some(LineRange {
852                start: 10,
853                end: 1000,
854            }),
855        );
856        // Lines 10..=59 are displayed; line 60 only decides the truncation flag.
857        assert_eq!(permalink.needed_lines(50), 60);
858    }
859
860    #[test]
861    fn needed_lines_saturates_on_huge_max_lines() {
862        let permalink = make_permalink("f.rs", None);
863        assert_eq!(permalink.needed_lines(usize::MAX), usize::MAX);
864
865        let ranged = make_permalink("f.rs", Some(LineRange { start: 1, end: 3 }));
866        assert_eq!(ranged.needed_lines(usize::MAX), 3);
867    }
868
869    // --- LimitedBody ---
870
871    /// Feeds `chunks` through a `LimitedBody`, stopping once it reports completion.
872    fn read_chunks(chunks: &[&[u8]], needed_lines: usize) -> Result<String, GitHubExpandError> {
873        let mut body = LimitedBody::new(needed_lines);
874        for chunk in chunks {
875            if body.is_complete() {
876                break;
877            }
878            body.push(chunk)?;
879        }
880        Ok(body.finish())
881    }
882
883    #[test]
884    fn limited_body_stops_after_needed_newlines() {
885        let result = read_chunks(&[b"a\nb\nc\nd\n"], 2).unwrap();
886        assert_eq!(result, "a\nb\n");
887    }
888
889    #[test]
890    fn limited_body_joins_chunk_boundaries() {
891        let result = read_chunks(&[b"hel", b"lo\nwor", b"ld\n"], 2).unwrap();
892        assert_eq!(result, "hello\nworld\n");
893    }
894
895    #[test]
896    fn limited_body_handles_newline_at_chunk_boundary() {
897        let result = read_chunks(&[b"a\n", b"b\n", b"c\n"], 2).unwrap();
898        assert_eq!(result, "a\nb\n");
899    }
900
901    #[test]
902    fn limited_body_keeps_partial_last_line_without_trailing_newline() {
903        // Fewer newlines than needed: the whole body is read and the unterminated
904        // last line is retained.
905        let result = read_chunks(&[b"a\nb\nc"], 5).unwrap();
906        assert_eq!(result, "a\nb\nc");
907    }
908
909    #[test]
910    fn limited_body_rejects_over_limit() {
911        // A single line longer than the limit: no newline ever satisfies the request.
912        let huge = vec![b'x'; MAX_BODY_BYTES + 1];
913        let err = read_chunks(&[&huge], 2).unwrap_err();
914        assert!(matches!(err, GitHubExpandError::ContentTooLarge));
915    }
916
917    #[test]
918    fn limited_body_rejects_over_limit_across_chunks() {
919        let half = vec![b'x'; MAX_BODY_BYTES / 2 + 1];
920        let err = read_chunks(&[&half, &half], 2).unwrap_err();
921        assert!(matches!(err, GitHubExpandError::ContentTooLarge));
922    }
923
924    #[test]
925    fn limited_body_accepts_when_needed_lines_met_before_limit() {
926        // The needed line ends one byte before the limit; the rest of the chunk
927        // would overshoot it but is discarded.
928        let mut chunk = vec![b'x'; MAX_BODY_BYTES - 1];
929        chunk.push(b'\n');
930        chunk.extend_from_slice(&vec![b'y'; MAX_BODY_BYTES]);
931
932        let result = read_chunks(&[&chunk], 1).unwrap();
933        assert_eq!(result.len(), MAX_BODY_BYTES);
934        assert!(result.ends_with('\n'));
935    }
936
937    #[test]
938    fn limited_body_decodes_utf8_split_across_chunks() {
939        // "あ" is E3 81 82; split it between two chunks.
940        let result = read_chunks(&[b"\xe3\x81", b"\x82\n"], 1).unwrap();
941        assert_eq!(result, "あ\n");
942    }
943
944    #[test]
945    fn limited_body_strips_leading_utf8_bom() {
946        let result = read_chunks(&[b"\xef\xbb\xbffn main() {}\n"], 1).unwrap();
947        assert_eq!(result, "fn main() {}\n");
948    }
949
950    #[test]
951    fn limited_body_keeps_bom_appearing_mid_body() {
952        let result = read_chunks(&["a\n\u{feff}b\n".as_bytes()], 2).unwrap();
953        assert_eq!(result, "a\n\u{feff}b\n");
954    }
955
956    #[test]
957    fn limited_body_decodes_utf16_read_in_full() {
958        // UTF-16LE BOM followed by "hi\n". A BOM selects its own encoding, so a body
959        // read in full decodes rather than turning into replacement characters.
960        let result = read_chunks(&[b"\xff\xfeh\0i\0\n\0"], 5).unwrap();
961        assert_eq!(result, "hi\n");
962    }
963
964    #[test]
965    fn limited_body_clips_utf16_when_stopping_early() {
966        // Known limitation: lines are counted in raw bytes, so stopping at the `0A`
967        // of a UTF-16LE `\n` (`0A 00`) drops the trailing `00` and leaves the final
968        // code unit incomplete. Only the boundary line is affected.
969        let result = read_chunks(&[b"\xff\xfeh\0i\0\n\0j\0\n\0"], 1).unwrap();
970        assert_eq!(result, "hi\u{fffd}");
971    }
972
973    #[test]
974    fn limited_body_replaces_invalid_utf8() {
975        let result = read_chunks(&[b"a\xffb\n"], 1).unwrap();
976        assert_eq!(result, "a\u{fffd}b\n");
977    }
978
979    #[test]
980    fn limited_body_with_zero_needed_lines_reads_nothing() {
981        let result = read_chunks(&[b"a\nb\n"], 0).unwrap();
982        assert_eq!(result, "");
983    }
984
985    // --- early truncation equivalence ---
986
987    fn code_block_parts(content: ExpandedContent) -> (String, String, String) {
988        match content {
989            ExpandedContent::CodeBlock {
990                language,
991                code,
992                metadata,
993            } => (language, code, metadata),
994            _ => panic!("expected CodeBlock"),
995        }
996    }
997
998    /// Asserts that reading only `needed_lines` produces the same code block as
999    /// reading the whole body — this is what makes the early stop safe.
1000    fn assert_truncated_read_matches_full(
1001        permalink: &GitHubPermalink,
1002        body: &str,
1003        max_lines: usize,
1004    ) {
1005        let truncated = read_chunks(&[body.as_bytes()], permalink.needed_lines(max_lines)).unwrap();
1006        assert_eq!(
1007            code_block_parts(permalink.build_code_block(&truncated, max_lines)),
1008            code_block_parts(permalink.build_code_block(body, max_lines)),
1009            "body: {body:?}, max_lines: {max_lines}"
1010        );
1011    }
1012
1013    #[test]
1014    fn early_read_matches_full_read_without_range() {
1015        let permalink = make_permalink("f.rs", None);
1016        let body = "a\nb\nc\nd\ne\nf\n";
1017        for max_lines in [1, 2, 5, 6, 50] {
1018            assert_truncated_read_matches_full(&permalink, body, max_lines);
1019        }
1020    }
1021
1022    #[test]
1023    fn early_read_matches_full_read_without_trailing_newline() {
1024        let permalink = make_permalink("f.rs", None);
1025        let body = "a\nb\nc";
1026        for max_lines in [1, 2, 3, 50] {
1027            assert_truncated_read_matches_full(&permalink, body, max_lines);
1028        }
1029    }
1030
1031    #[test]
1032    fn early_read_matches_full_read_with_range() {
1033        let body = "a\nb\nc\nd\ne\nf\ng\nh\n";
1034        let ranges = [
1035            LineRange { start: 1, end: 3 },
1036            LineRange { start: 3, end: 5 },
1037            LineRange { start: 2, end: 100 },
1038            LineRange {
1039                start: 100,
1040                end: 200,
1041            },
1042        ];
1043        for range in ranges {
1044            let permalink = make_permalink("f.rs", Some(range));
1045            for max_lines in [1, 2, 3, 50] {
1046                assert_truncated_read_matches_full(&permalink, body, max_lines);
1047            }
1048        }
1049    }
1050
1051    // --- is_commit_sha / shorten_ref ---
1052
1053    #[test]
1054    fn is_commit_sha_valid() {
1055        assert!(is_commit_sha("abcd1234"));
1056        assert!(is_commit_sha("abcdef1234567890abcdef1234567890abcdef12"));
1057    }
1058
1059    #[test]
1060    fn is_commit_sha_boundary() {
1061        // Exactly 4 hex chars (minimum)
1062        assert!(is_commit_sha("abcd"));
1063        // Exactly 40 hex chars (full SHA-1)
1064        assert!(is_commit_sha("abcdef1234567890abcdef1234567890abcdef12"));
1065    }
1066
1067    #[test]
1068    fn is_commit_sha_invalid() {
1069        assert!(!is_commit_sha("main"));
1070        assert!(!is_commit_sha("develop"));
1071        assert!(!is_commit_sha("abc")); // too short
1072        assert!(!is_commit_sha("abcdef1234567890abcdef1234567890abcdef123")); // too long (41)
1073        assert!(!is_commit_sha("ghijkl")); // non-hex
1074        assert!(!is_commit_sha("")); // empty
1075        assert!(is_commit_sha("ABCD1234")); // uppercase hex is still valid hex
1076    }
1077
1078    #[test]
1079    fn shorten_ref_commit() {
1080        assert_eq!(shorten_ref("abcdef1234567890"), "abcdef1");
1081    }
1082
1083    #[test]
1084    fn shorten_ref_short_sha() {
1085        // 4-char SHA should not be truncated further
1086        assert_eq!(shorten_ref("abcd"), "abcd");
1087    }
1088
1089    #[test]
1090    fn shorten_ref_branch() {
1091        assert_eq!(shorten_ref("main"), "main");
1092        assert_eq!(shorten_ref("feature-branch"), "feature-branch");
1093        assert_eq!(shorten_ref("release-v1.0"), "release-v1.0");
1094        assert_eq!(shorten_ref("v2.0.0"), "v2.0.0");
1095    }
1096}