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
43const MAX_BODY_BYTES: usize = 1_048_576;
49
50#[derive(Debug)]
52pub struct GitHubPermalink {
53 pub owner: String,
55 pub repo: String,
57 pub git_ref: String,
59 pub path: String,
61 pub line_range: Option<LineRange>,
63}
64
65#[derive(Debug, Clone, Copy)]
67pub struct LineRange {
68 pub start: usize,
70 pub end: usize,
72}
73
74pub 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 #[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 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#[derive(thiserror::Error, Debug)]
119pub enum GitHubExpandError {
120 #[error("Failed to fetch raw content: {0}")]
122 Fetch(String),
123 #[error("Content exceeds size limit")]
125 ContentTooLarge,
126 #[error(transparent)]
128 Http(#[from] reqwest::Error),
129}
130
131impl GitHubPermalink {
132 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 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 #[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 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 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); 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#[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
314struct 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 fn is_complete(&self) -> bool {
333 self.newlines >= self.needed_lines
334 }
335
336 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 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 fn finish(self) -> String {
384 encoding_rs::UTF_8.decode(&self.buf).0.into_owned()
385 }
386}
387
388fn is_commit_sha(s: &str) -> bool {
390 (4..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
391}
392
393fn 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
403fn 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 #[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 #[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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 assert!(is_commit_sha("abcd"));
1063 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")); assert!(!is_commit_sha("abcdef1234567890abcdef1234567890abcdef123")); assert!(!is_commit_sha("ghijkl")); assert!(!is_commit_sha("")); assert!(is_commit_sha("ABCD1234")); }
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 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}