1use 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
15pub struct HttpClient;
17
18impl TypeMapKey for HttpClient {
19 type Value = reqwest::Client;
20}
21
22static 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#[derive(Debug)]
45pub struct GitHubPermalink {
46 pub owner: String,
48 pub repo: String,
50 pub git_ref: String,
52 pub path: String,
54 pub line_range: Option<LineRange>,
56}
57
58#[derive(Debug, Clone, Copy)]
60pub struct LineRange {
61 pub start: usize,
63 pub end: usize,
65}
66
67pub 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 #[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 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#[derive(thiserror::Error, Debug)]
112pub enum GitHubExpandError {
113 #[error("Failed to fetch raw content: {0}")]
115 Fetch(String),
116 #[error("Content exceeds size limit")]
118 ContentTooLarge,
119 #[error(transparent)]
121 Http(#[from] reqwest::Error),
122}
123
124impl GitHubPermalink {
125 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 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 #[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 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 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); 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
269fn is_commit_sha(s: &str) -> bool {
271 (4..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
272}
273
274fn 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
284fn 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 #[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 #[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 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 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 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 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 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 #[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 assert!(is_commit_sha("abcd"));
726 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")); assert!(!is_commit_sha("abcdef1234567890abcdef1234567890abcdef123")); assert!(!is_commit_sha("ghijkl")); assert!(!is_commit_sha("")); assert!(is_commit_sha("ABCD1234")); }
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 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}