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/// A parsed GitHub permalink.
44#[derive(Debug)]
45pub struct GitHubPermalink {
46    /// Repository owner.
47    pub owner: String,
48    /// Repository name.
49    pub repo: String,
50    /// Git ref (commit SHA or branch/tag name).
51    pub git_ref: String,
52    /// File path within the repository.
53    pub path: String,
54    /// Optional line range specification.
55    pub line_range: Option<LineRange>,
56}
57
58/// A line range extracted from a GitHub permalink fragment.
59#[derive(Debug, Clone, Copy)]
60pub struct LineRange {
61    /// Start line (1-indexed).
62    pub start: usize,
63    /// End line (1-indexed, inclusive). Same as `start` for single-line references.
64    pub end: usize,
65}
66
67/// GitHub permalink expander.
68pub struct GitHubExpander;
69
70#[serenity::async_trait]
71impl LinkExpander for GitHubExpander {
72    fn enabled(&self, config: &BabyriteConfig) -> bool {
73        config.features.github_permalink
74    }
75
76    /// Expands GitHub permalinks into code blocks.
77    #[cfg_attr(coverage_nightly, coverage(off))]
78    async fn expand_all(&self, cx: &ExpandContext<'_>) -> Vec<ExpandedContent> {
79        let permalinks = GitHubPermalink::parse_all(&cx.message.content);
80        if permalinks.is_empty() {
81            return Vec::new();
82        }
83        tracing::debug!(count = permalinks.len(), "parsed GitHub permalinks");
84
85        // `reqwest::Client` is internally reference-counted, so clone it out of
86        // the TypeMap instead of holding the read guard across the fetches below.
87        let http_client = {
88            let data = cx.ctx.data.read().await;
89            data.get::<HttpClient>().cloned()
90        };
91        let Some(http_client) = http_client else {
92            tracing::error!("HTTP client not found in TypeMap");
93            return Vec::new();
94        };
95
96        join_all(permalinks.iter().map(|p| p.fetch(&http_client)))
97            .await
98            .into_iter()
99            .filter_map(|result| match result {
100                Ok(content) => Some(content),
101                Err(e) => {
102                    tracing::error!(error = %e, "failed to expand GitHub permalink");
103                    None
104                }
105            })
106            .collect()
107    }
108}
109
110/// Errors that can occur when expanding a GitHub permalink.
111#[derive(thiserror::Error, Debug)]
112pub enum GitHubExpandError {
113    /// Failed to fetch the raw file content.
114    #[error("Failed to fetch raw content: {0}")]
115    Fetch(String),
116    /// The fetched content exceeds the maximum allowed size.
117    #[error("Content exceeds size limit")]
118    ContentTooLarge,
119    /// An HTTP error occurred.
120    #[error(transparent)]
121    Http(#[from] reqwest::Error),
122}
123
124impl GitHubPermalink {
125    /// Parses all GitHub permalink URLs from the given text.
126    ///
127    /// Matches URLs with commit SHAs, branch names, or tag names.
128    /// The shared link policy applies (see [`super::parse_links`]): angle-bracket
129    /// wrapped and duplicate URLs are ignored, and at most 3 links are returned.
130    pub fn parse_all(text: &str) -> Vec<GitHubPermalink> {
131        super::parse_links(text, &GITHUB_PERMALINK_REGEX, |captures| {
132            let line_range = match captures.get(5) {
133                Some(start) => {
134                    let start = start.as_str().parse().ok()?;
135                    // A missing end (e.g. `#L42`) means a single-line reference.
136                    let end = match captures.get(6) {
137                        Some(end) => end.as_str().parse().ok()?,
138                        None => start,
139                    };
140                    Some(LineRange { start, end })
141                }
142                None => None,
143            };
144
145            Some(GitHubPermalink {
146                owner: captures.get(1)?.as_str().to_string(),
147                repo: captures.get(2)?.as_str().to_string(),
148                git_ref: captures.get(3)?.as_str().to_string(),
149                path: captures.get(4)?.as_str().to_string(),
150                line_range,
151            })
152        })
153    }
154
155    /// Fetches the raw file content from GitHub and returns a code block.
156    #[cfg_attr(coverage_nightly, coverage(off))]
157    #[tracing::instrument(
158        skip(self, http_client),
159        fields(
160            owner = %self.owner,
161            repo = %self.repo,
162            git_ref = %self.git_ref,
163            path = %self.path,
164        )
165    )]
166    pub async fn fetch(
167        &self,
168        http_client: &reqwest::Client,
169    ) -> Result<ExpandedContent, ExpandError> {
170        let config = BabyriteConfig::get();
171        let max_lines = config.github.max_lines;
172
173        let raw_url = format!(
174            "https://raw.githubusercontent.com/{}/{}/{}/{}",
175            self.owner, self.repo, self.git_ref, self.path
176        );
177
178        tracing::debug!(url = %raw_url, "fetching raw content");
179        let started = std::time::Instant::now();
180        let response = http_client
181            .get(&raw_url)
182            .send()
183            .await
184            .map_err(GitHubExpandError::Http)?;
185
186        let content_length = response.content_length().unwrap_or(0);
187        tracing::debug!(
188            status = %response.status(),
189            content_length,
190            elapsed_ms = started.elapsed().as_millis(),
191            "received response"
192        );
193
194        if !response.status().is_success() {
195            tracing::warn!(status = %response.status(), "non-success status fetching raw content");
196            return Err(GitHubExpandError::Fetch(format!(
197                "HTTP {} for {}",
198                response.status(),
199                raw_url
200            ))
201            .into());
202        }
203
204        // 1 MB limit to avoid fetching huge files
205        if content_length > 1_048_576 {
206            tracing::warn!(content_length, "content exceeds size limit");
207            return Err(GitHubExpandError::ContentTooLarge.into());
208        }
209
210        let body = response.text().await.map_err(GitHubExpandError::Http)?;
211
212        let content = self.build_code_block(&body, max_lines);
213        tracing::debug!("code block built");
214        Ok(content)
215    }
216
217    /// Builds an `ExpandedContent::CodeBlock` from raw file content.
218    fn build_code_block(&self, body: &str, max_lines: usize) -> ExpandedContent {
219        let all_lines: Vec<&str> = body.lines().collect();
220        let (code, line_info) = match self.line_range {
221            Some(range) => {
222                let start = range.start.saturating_sub(1); // 0-indexed
223                let end = range.end.min(all_lines.len());
224                let selected = all_lines.get(start..end).unwrap_or_default();
225
226                let (code, truncated) = truncate_lines(selected, max_lines);
227                let info = if truncated {
228                    format!(
229                        "L{}-L{}, truncated to {} lines",
230                        range.start, range.end, max_lines
231                    )
232                } else {
233                    format!("L{}-L{}", range.start, range.end)
234                };
235                (code, info)
236            }
237            None => {
238                let (code, truncated) = truncate_lines(&all_lines, max_lines);
239                let info = if truncated {
240                    format!("truncated to {} lines", max_lines)
241                } else {
242                    String::new()
243                };
244                (code, info)
245            }
246        };
247
248        let display_ref = shorten_ref(&self.git_ref);
249        let language = language_for_path(&self.path);
250
251        let line_part = if line_info.is_empty() {
252            String::new()
253        } else {
254            format!(" ({line_info})")
255        };
256        let metadata = format!(
257            "`{}`{} - {}/{}@{}",
258            self.path, line_part, self.owner, self.repo, display_ref
259        );
260
261        ExpandedContent::CodeBlock {
262            language: language.to_string(),
263            code,
264            metadata,
265        }
266    }
267}
268
269/// Returns true if the given string looks like a commit SHA (4-40 hex characters).
270fn is_commit_sha(s: &str) -> bool {
271    (4..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
272}
273
274/// Shortens a git ref for display. Commit SHAs are truncated to 7 characters;
275/// branch/tag names are returned as-is.
276fn shorten_ref(git_ref: &str) -> &str {
277    if is_commit_sha(git_ref) {
278        &git_ref[..7.min(git_ref.len())]
279    } else {
280        git_ref
281    }
282}
283
284/// Truncates lines to the given maximum, returning the joined string and whether truncation occurred.
285fn truncate_lines(lines: &[&str], max: usize) -> (String, bool) {
286    let kept = &lines[..lines.len().min(max)];
287    (kept.join("\n"), lines.len() > max)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    // --- truncate_lines ---
295
296    #[test]
297    fn truncate_lines_under_limit() {
298        let lines = vec!["a", "b", "c"];
299        let (result, truncated) = truncate_lines(&lines, 5);
300        assert_eq!(result, "a\nb\nc");
301        assert!(!truncated);
302    }
303
304    #[test]
305    fn truncate_lines_at_limit() {
306        let lines = vec!["a", "b", "c"];
307        let (result, truncated) = truncate_lines(&lines, 3);
308        assert_eq!(result, "a\nb\nc");
309        assert!(!truncated);
310    }
311
312    #[test]
313    fn truncate_lines_over_limit() {
314        let lines = vec!["a", "b", "c", "d", "e"];
315        let (result, truncated) = truncate_lines(&lines, 2);
316        assert_eq!(result, "a\nb");
317        assert!(truncated);
318    }
319
320    #[test]
321    fn truncate_lines_empty() {
322        let lines: Vec<&str> = vec![];
323        let (result, truncated) = truncate_lines(&lines, 5);
324        assert_eq!(result, "");
325        assert!(!truncated);
326    }
327
328    // --- GitHubPermalink::parse_all ---
329
330    #[test]
331    fn parse_basic_permalink() {
332        let text = "https://github.com/owner/repo/blob/abcdef1234567890abcdef1234567890abcdef12/src/main.rs";
333        let results = GitHubPermalink::parse_all(text);
334        assert_eq!(results.len(), 1);
335        assert_eq!(results[0].owner, "owner");
336        assert_eq!(results[0].repo, "repo");
337        assert_eq!(
338            results[0].git_ref,
339            "abcdef1234567890abcdef1234567890abcdef12"
340        );
341        assert_eq!(results[0].path, "src/main.rs");
342        assert!(results[0].line_range.is_none());
343    }
344
345    #[test]
346    fn parse_permalink_with_single_line() {
347        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs#L42";
348        let results = GitHubPermalink::parse_all(text);
349        assert_eq!(results.len(), 1);
350        let range = results[0].line_range.unwrap();
351        assert_eq!(range.start, 42);
352        assert_eq!(range.end, 42);
353    }
354
355    #[test]
356    fn parse_permalink_with_line_range() {
357        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs#L10-L20";
358        let results = GitHubPermalink::parse_all(text);
359        assert_eq!(results.len(), 1);
360        let range = results[0].line_range.unwrap();
361        assert_eq!(range.start, 10);
362        assert_eq!(range.end, 20);
363    }
364
365    #[test]
366    fn parse_branch_name() {
367        let text = "https://github.com/owner/repo/blob/main/src/lib.rs";
368        let results = GitHubPermalink::parse_all(text);
369        assert_eq!(results.len(), 1);
370        assert_eq!(results[0].git_ref, "main");
371        assert_eq!(results[0].path, "src/lib.rs");
372    }
373
374    #[test]
375    fn parse_branch_name_with_line_range() {
376        let text = "https://github.com/owner/repo/blob/develop/src/main.rs#L5-L10";
377        let results = GitHubPermalink::parse_all(text);
378        assert_eq!(results.len(), 1);
379        assert_eq!(results[0].git_ref, "develop");
380        let range = results[0].line_range.unwrap();
381        assert_eq!(range.start, 5);
382        assert_eq!(range.end, 10);
383    }
384
385    #[test]
386    fn parse_branch_name_with_single_line() {
387        let text = "https://github.com/owner/repo/blob/main/src/lib.rs#L5";
388        let results = GitHubPermalink::parse_all(text);
389        assert_eq!(results.len(), 1);
390        assert_eq!(results[0].git_ref, "main");
391        let range = results[0].line_range.unwrap();
392        assert_eq!(range.start, 5);
393        assert_eq!(range.end, 5);
394    }
395
396    #[test]
397    fn parse_branch_with_special_characters() {
398        let cases = [
399            (
400                "https://github.com/o/r/blob/release-v1.0/f.rs",
401                "release-v1.0",
402            ),
403            (
404                "https://github.com/o/r/blob/feat_something/f.rs",
405                "feat_something",
406            ),
407            ("https://github.com/o/r/blob/v2.0.0/f.rs", "v2.0.0"),
408        ];
409        for (text, expected_ref) in cases {
410            let results = GitHubPermalink::parse_all(text);
411            assert_eq!(results.len(), 1, "failed for: {text}");
412            assert_eq!(results[0].git_ref, expected_ref);
413        }
414    }
415
416    #[test]
417    fn parse_tag_name() {
418        let text = "https://github.com/owner/repo/blob/v1.0.0/src/main.rs#L1-L10";
419        let results = GitHubPermalink::parse_all(text);
420        assert_eq!(results.len(), 1);
421        assert_eq!(results[0].git_ref, "v1.0.0");
422        let range = results[0].line_range.unwrap();
423        assert_eq!(range.start, 1);
424        assert_eq!(range.end, 10);
425    }
426
427    #[test]
428    fn parse_mixed_sha_and_branch() {
429        let text = "https://github.com/o/r/blob/abcd1234/a.rs \
430                    https://github.com/o/r/blob/main/b.rs";
431        let results = GitHubPermalink::parse_all(text);
432        assert_eq!(results.len(), 2);
433        assert_eq!(results[0].git_ref, "abcd1234");
434        assert_eq!(results[1].git_ref, "main");
435    }
436
437    #[test]
438    fn parse_accepts_short_ref() {
439        // Short refs (e.g., short branch names) should still match
440        let text = "https://github.com/owner/repo/blob/abc/src/lib.rs";
441        let results = GitHubPermalink::parse_all(text);
442        assert_eq!(results.len(), 1);
443        assert_eq!(results[0].git_ref, "abc");
444    }
445
446    #[test]
447    fn parse_deduplicates_urls() {
448        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs \
449                    https://github.com/owner/repo/blob/abcd1234/src/lib.rs";
450        let results = GitHubPermalink::parse_all(text);
451        assert_eq!(results.len(), 1);
452    }
453
454    #[test]
455    fn parse_limits_to_three() {
456        let text = "\
457            https://github.com/o/r/blob/aaaa1111/a.rs \
458            https://github.com/o/r/blob/bbbb2222/b.rs \
459            https://github.com/o/r/blob/cccc3333/c.rs \
460            https://github.com/o/r/blob/dddd4444/d.rs";
461        let results = GitHubPermalink::parse_all(text);
462        assert_eq!(results.len(), 3);
463    }
464
465    #[test]
466    fn parse_multiple_different_urls() {
467        let text = "Check https://github.com/a/b/blob/1111aaaa/x.rs#L1 and \
468                    https://github.com/c/d/blob/2222bbbb/y.py#L5-L10";
469        let results = GitHubPermalink::parse_all(text);
470        assert_eq!(results.len(), 2);
471        assert_eq!(results[0].owner, "a");
472        assert_eq!(results[1].owner, "c");
473        assert_eq!(results[1].path, "y.py");
474    }
475
476    #[test]
477    fn parse_no_match() {
478        let text = "Hello, no links here!";
479        let results = GitHubPermalink::parse_all(text);
480        assert!(results.is_empty());
481    }
482
483    #[test]
484    fn parse_permalink_with_query() {
485        let text = "https://github.com/owner/repo/blob/abcd1234/README.md?plain=1";
486        let results = GitHubPermalink::parse_all(text);
487        assert_eq!(results.len(), 1);
488        assert_eq!(results[0].path, "README.md");
489        assert!(results[0].line_range.is_none());
490    }
491
492    #[test]
493    fn parse_permalink_with_query_and_line_range() {
494        // Regression test for issue #593: the URL reported in the issue.
495        let text = "https://github.com/m1sk9/dotfiles/blob/02962edfa2d9f5e1ed3f9a7cded1055b1a64b03d/private_dot_claude/CLAUDE.md?plain=1#L21-L46";
496        let results = GitHubPermalink::parse_all(text);
497        assert_eq!(results.len(), 1);
498        assert_eq!(results[0].owner, "m1sk9");
499        assert_eq!(results[0].repo, "dotfiles");
500        assert_eq!(
501            results[0].git_ref,
502            "02962edfa2d9f5e1ed3f9a7cded1055b1a64b03d"
503        );
504        assert_eq!(results[0].path, "private_dot_claude/CLAUDE.md");
505        let range = results[0].line_range.unwrap();
506        assert_eq!(range.start, 21);
507        assert_eq!(range.end, 46);
508    }
509
510    #[test]
511    fn parse_permalink_with_query_and_single_line() {
512        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs?plain=1#L10";
513        let results = GitHubPermalink::parse_all(text);
514        assert_eq!(results.len(), 1);
515        assert_eq!(results[0].path, "src/lib.rs");
516        let range = results[0].line_range.unwrap();
517        assert_eq!(range.start, 10);
518        assert_eq!(range.end, 10);
519    }
520
521    #[test]
522    fn parse_permalink_with_multiple_query_params() {
523        let text = "https://github.com/owner/repo/blob/abcd1234/src/lib.rs?foo=bar&baz=qux#L1-L2";
524        let results = GitHubPermalink::parse_all(text);
525        assert_eq!(results.len(), 1);
526        assert_eq!(results[0].path, "src/lib.rs");
527        let range = results[0].line_range.unwrap();
528        assert_eq!(range.start, 1);
529        assert_eq!(range.end, 2);
530    }
531
532    #[test]
533    fn parse_ignores_angle_bracket_link() {
534        let text = "<https://github.com/owner/repo/blob/abcd1234/src/lib.rs#L10-L20>";
535        let results = GitHubPermalink::parse_all(text);
536        assert!(results.is_empty());
537    }
538
539    #[test]
540    fn parse_nested_path() {
541        let text = "https://github.com/owner/repo/blob/abcd1234/src/deeply/nested/path/file.rs";
542        let results = GitHubPermalink::parse_all(text);
543        assert_eq!(results.len(), 1);
544        assert_eq!(results[0].path, "src/deeply/nested/path/file.rs");
545    }
546
547    #[test]
548    fn parse_short_commit_sha() {
549        // 4-character SHA is the minimum
550        let text = "https://github.com/owner/repo/blob/abcd/file.rs";
551        let results = GitHubPermalink::parse_all(text);
552        assert_eq!(results.len(), 1);
553        assert_eq!(results[0].git_ref, "abcd");
554    }
555
556    // --- build_code_block ---
557
558    fn make_permalink(path: &str, line_range: Option<LineRange>) -> GitHubPermalink {
559        GitHubPermalink {
560            owner: "owner".to_string(),
561            repo: "repo".to_string(),
562            git_ref: "abcdef1234567".to_string(),
563            path: path.to_string(),
564            line_range,
565        }
566    }
567
568    #[test]
569    fn build_code_block_full_file() {
570        let permalink = make_permalink("src/main.rs", None);
571        let body = "fn main() {\n    println!(\"hello\");\n}";
572        let result = permalink.build_code_block(body, 50);
573
574        match result {
575            ExpandedContent::CodeBlock {
576                language,
577                code,
578                metadata,
579            } => {
580                assert_eq!(language, "rust");
581                assert_eq!(code, body);
582                assert_eq!(metadata, "`src/main.rs` - owner/repo@abcdef1");
583            }
584            _ => panic!("expected CodeBlock"),
585        }
586    }
587
588    #[test]
589    fn build_code_block_with_line_range() {
590        let permalink = make_permalink("src/lib.rs", Some(LineRange { start: 2, end: 3 }));
591        let body = "line1\nline2\nline3\nline4";
592        let result = permalink.build_code_block(body, 50);
593
594        match result {
595            ExpandedContent::CodeBlock {
596                language,
597                code,
598                metadata,
599            } => {
600                assert_eq!(language, "rust");
601                assert_eq!(code, "line2\nline3");
602                assert!(metadata.contains("L2-L3"));
603            }
604            _ => panic!("expected CodeBlock"),
605        }
606    }
607
608    #[test]
609    fn build_code_block_truncated() {
610        let permalink = make_permalink("app.py", None);
611        let body = "a\nb\nc\nd\ne";
612        let result = permalink.build_code_block(body, 2);
613
614        match result {
615            ExpandedContent::CodeBlock { code, metadata, .. } => {
616                assert_eq!(code, "a\nb");
617                assert!(metadata.contains("truncated to 2 lines"));
618            }
619            _ => panic!("expected CodeBlock"),
620        }
621    }
622
623    #[test]
624    fn build_code_block_line_range_truncated() {
625        let permalink = make_permalink("app.py", Some(LineRange { start: 1, end: 5 }));
626        let body = "a\nb\nc\nd\ne";
627        let result = permalink.build_code_block(body, 3);
628
629        match result {
630            ExpandedContent::CodeBlock { code, metadata, .. } => {
631                assert_eq!(code, "a\nb\nc");
632                assert!(metadata.contains("L1-L5"));
633                assert!(metadata.contains("truncated to 3 lines"));
634            }
635            _ => panic!("expected CodeBlock"),
636        }
637    }
638
639    #[test]
640    fn build_code_block_dockerfile_language() {
641        let permalink = make_permalink("docker/Dockerfile", None);
642        let body = "FROM rust:latest";
643        let result = permalink.build_code_block(body, 50);
644
645        match result {
646            ExpandedContent::CodeBlock { language, .. } => {
647                assert_eq!(language, "dockerfile");
648            }
649            _ => panic!("expected CodeBlock"),
650        }
651    }
652
653    #[test]
654    fn build_code_block_short_commit() {
655        let permalink = GitHubPermalink {
656            owner: "o".to_string(),
657            repo: "r".to_string(),
658            git_ref: "abcd".to_string(),
659            path: "f.rs".to_string(),
660            line_range: None,
661        };
662        let result = permalink.build_code_block("x", 50);
663
664        match result {
665            ExpandedContent::CodeBlock { metadata, .. } => {
666                assert!(metadata.contains("o/r@abcd"));
667            }
668            _ => panic!("expected CodeBlock"),
669        }
670    }
671
672    #[test]
673    fn build_code_block_branch_ref() {
674        let permalink = GitHubPermalink {
675            owner: "o".to_string(),
676            repo: "r".to_string(),
677            git_ref: "main".to_string(),
678            path: "f.rs".to_string(),
679            line_range: None,
680        };
681        let result = permalink.build_code_block("x", 50);
682
683        match result {
684            ExpandedContent::CodeBlock { metadata, .. } => {
685                // Branch names should not be truncated
686                assert!(metadata.contains("o/r@main"));
687            }
688            _ => panic!("expected CodeBlock"),
689        }
690    }
691
692    #[test]
693    fn build_code_block_branch_ref_with_line_range() {
694        let permalink = GitHubPermalink {
695            owner: "o".to_string(),
696            repo: "r".to_string(),
697            git_ref: "develop".to_string(),
698            path: "src/lib.rs".to_string(),
699            line_range: Some(LineRange { start: 3, end: 5 }),
700        };
701        let body = "a\nb\nc\nd\ne\nf";
702        let result = permalink.build_code_block(body, 50);
703
704        match result {
705            ExpandedContent::CodeBlock { code, metadata, .. } => {
706                assert_eq!(code, "c\nd\ne");
707                assert!(metadata.contains("L3-L5"));
708                assert!(metadata.contains("o/r@develop"));
709            }
710            _ => panic!("expected CodeBlock"),
711        }
712    }
713
714    // --- is_commit_sha / shorten_ref ---
715
716    #[test]
717    fn is_commit_sha_valid() {
718        assert!(is_commit_sha("abcd1234"));
719        assert!(is_commit_sha("abcdef1234567890abcdef1234567890abcdef12"));
720    }
721
722    #[test]
723    fn is_commit_sha_boundary() {
724        // Exactly 4 hex chars (minimum)
725        assert!(is_commit_sha("abcd"));
726        // Exactly 40 hex chars (full SHA-1)
727        assert!(is_commit_sha("abcdef1234567890abcdef1234567890abcdef12"));
728    }
729
730    #[test]
731    fn is_commit_sha_invalid() {
732        assert!(!is_commit_sha("main"));
733        assert!(!is_commit_sha("develop"));
734        assert!(!is_commit_sha("abc")); // too short
735        assert!(!is_commit_sha("abcdef1234567890abcdef1234567890abcdef123")); // too long (41)
736        assert!(!is_commit_sha("ghijkl")); // non-hex
737        assert!(!is_commit_sha("")); // empty
738        assert!(is_commit_sha("ABCD1234")); // uppercase hex is still valid hex
739    }
740
741    #[test]
742    fn shorten_ref_commit() {
743        assert_eq!(shorten_ref("abcdef1234567890"), "abcdef1");
744    }
745
746    #[test]
747    fn shorten_ref_short_sha() {
748        // 4-char SHA should not be truncated further
749        assert_eq!(shorten_ref("abcd"), "abcd");
750    }
751
752    #[test]
753    fn shorten_ref_branch() {
754        assert_eq!(shorten_ref("main"), "main");
755        assert_eq!(shorten_ref("feature-branch"), "feature-branch");
756        assert_eq!(shorten_ref("release-v1.0"), "release-v1.0");
757        assert_eq!(shorten_ref("v2.0.0"), "v2.0.0");
758    }
759}