1use url::Url;
4
5pub const INLINE_MARKDOWN_LINE_LIMIT: usize = 1500;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct TextPasteUrl {
9 pub paste_id: String,
10 path_prefix: String,
11}
12
13#[must_use]
14pub fn is_text_paste_url(url: &str) -> bool {
15 parse_text_paste_url(url).is_some()
16}
17
18#[must_use]
19pub fn normalize_url_for_text_content(url: &str) -> String {
20 parse_text_paste_url(url).map_or_else(
21 || url.to_string(),
22 |paste| {
23 format!(
24 "https://xpaste.pro{}/p/{}/raw",
25 paste.path_prefix, paste.paste_id
26 )
27 },
28 )
29}
30
31#[must_use]
32pub fn normalize_url_for_text_page(url: &str) -> String {
33 parse_text_paste_url(url).map_or_else(
34 || url.to_string(),
35 |paste| {
36 format!(
37 "https://xpaste.pro{}/p/{}",
38 paste.path_prefix, paste.paste_id
39 )
40 },
41 )
42}
43
44#[must_use]
45pub fn paste_id(url: &str) -> Option<String> {
46 parse_text_paste_url(url).map(|paste| paste.paste_id)
47}
48
49#[must_use]
50pub fn filename_for_text_url(url: &str) -> String {
51 if let Some(paste) = parse_text_paste_url(url) {
52 return format!("xpaste-pro-{}.txt", paste.paste_id);
53 }
54
55 Url::parse(url).map_or_else(
56 |_| "download.txt".to_string(),
57 |parsed| {
58 let host = parsed.host_str().unwrap_or("download").replace('.', "-");
59 let path = parsed
60 .path()
61 .trim_matches('/')
62 .replace('/', "-")
63 .replace("-raw", "");
64 if path.is_empty() {
65 format!("{host}.txt")
66 } else {
67 format!("{host}-{path}.txt")
68 }
69 },
70 )
71}
72
73#[must_use]
74pub fn append_text_attachment_markdown(markdown: &str, url: &str, raw_text: &str) -> String {
75 let filename = filename_for_text_url(url);
76 let normalized_raw_text = normalize_attachment_text(raw_text);
77 let fence = markdown_fence_for(&normalized_raw_text);
78 let mut result = String::with_capacity(markdown.len() + normalized_raw_text.len() + 128);
79 result.push_str(markdown.trim_end());
80 result.push_str("\n\n## ");
81 result.push_str(&filename);
82 result.push_str("\n\n");
83 result.push_str(&fence);
84 result.push_str("text\n");
85 result.push_str(&normalized_raw_text);
86 if !normalized_raw_text.ends_with('\n') {
87 result.push('\n');
88 }
89 result.push_str(&fence);
90 result.push('\n');
91 result
92}
93
94fn normalize_attachment_text(text: &str) -> String {
95 text.replace("\r\n", "\n").replace('\r', "\n")
96}
97
98fn markdown_fence_for(text: &str) -> String {
99 let mut longest_run = 0;
100 let mut current_run = 0;
101 for ch in text.chars() {
102 if ch == '`' {
103 current_run += 1;
104 longest_run = longest_run.max(current_run);
105 } else {
106 current_run = 0;
107 }
108 }
109 "`".repeat((longest_run + 1).max(3))
110}
111
112fn parse_text_paste_url(url: &str) -> Option<TextPasteUrl> {
113 let parsed = Url::parse(url).ok()?;
114 let host = parsed.host_str()?.to_ascii_lowercase();
115 if host != "xpaste.pro" && host != "www.xpaste.pro" {
116 return None;
117 }
118
119 let parts: Vec<&str> = parsed.path_segments()?.collect();
120 let index = parts.iter().position(|part| *part == "p")?;
121 let mut path_prefix = String::new();
122 if index == 1 && matches!(parts.first(), Some(&("en" | "ru"))) {
123 path_prefix = format!("/{}", parts[0]);
124 } else if index != 0 {
125 return None;
126 }
127
128 let paste_id = parts.get(index + 1)?.to_string();
129 if paste_id.is_empty() {
130 return None;
131 }
132
133 let tail = &parts[index + 2..];
134 if tail.len() > 1 || tail.first().is_some_and(|part| *part != "raw") {
135 return None;
136 }
137
138 Some(TextPasteUrl {
139 paste_id,
140 path_prefix,
141 })
142}
143
144#[cfg(test)]
145mod tests {
146 use super::{
147 append_text_attachment_markdown, filename_for_text_url, is_text_paste_url,
148 normalize_url_for_text_content, normalize_url_for_text_page,
149 };
150
151 #[test]
152 fn normalizes_xpaste_urls_to_raw_text() {
153 assert_eq!(
154 normalize_url_for_text_content("https://xpaste.pro/p/t4q0Lsp0"),
155 "https://xpaste.pro/p/t4q0Lsp0/raw"
156 );
157 assert_eq!(
158 normalize_url_for_text_content("https://xpaste.pro/ru/p/t4q0Lsp0"),
159 "https://xpaste.pro/ru/p/t4q0Lsp0/raw"
160 );
161 assert_eq!(
162 normalize_url_for_text_content("https://xpaste.pro/en/p/t4q0Lsp0/raw"),
163 "https://xpaste.pro/en/p/t4q0Lsp0/raw"
164 );
165 }
166
167 #[test]
168 fn normalizes_xpaste_raw_urls_to_visual_page() {
169 assert_eq!(
170 normalize_url_for_text_page("https://xpaste.pro/p/t4q0Lsp0/raw"),
171 "https://xpaste.pro/p/t4q0Lsp0"
172 );
173 assert_eq!(
174 normalize_url_for_text_page("https://xpaste.pro/ru/p/t4q0Lsp0/raw"),
175 "https://xpaste.pro/ru/p/t4q0Lsp0"
176 );
177 assert_eq!(
178 normalize_url_for_text_page("https://example.com/page"),
179 "https://example.com/page"
180 );
181 }
182
183 #[test]
184 fn detects_only_supported_xpaste_paste_urls() {
185 assert!(is_text_paste_url("https://xpaste.pro/p/t4q0Lsp0"));
186 assert!(is_text_paste_url("https://xpaste.pro/ru/p/t4q0Lsp0"));
187 assert!(is_text_paste_url("https://xpaste.pro/en/p/t4q0Lsp0"));
188 assert!(!is_text_paste_url("https://xpaste.pro/about"));
189 assert!(!is_text_paste_url("https://xpaste.pro/foo/p/t4q0Lsp0"));
190 assert!(!is_text_paste_url(
191 "https://xpaste.pro/p/t4q0Lsp0/raw/extra"
192 ));
193 assert!(!is_text_paste_url("https://example.com/p/t4q0Lsp0"));
194 assert!(!is_text_paste_url("not a url"));
195 }
196
197 #[test]
198 fn derives_text_download_filename() {
199 assert_eq!(
200 filename_for_text_url("https://xpaste.pro/p/t4q0Lsp0"),
201 "xpaste-pro-t4q0Lsp0.txt"
202 );
203 }
204
205 #[test]
206 fn embeds_raw_text_as_named_markdown_attachment() {
207 let markdown = "# Page\n\nVisible content";
208 let raw = "first line\n```inside paste```\nlast line";
209 let result =
210 append_text_attachment_markdown(markdown, "https://xpaste.pro/p/t4q0Lsp0", raw);
211
212 assert!(result.contains("## xpaste-pro-t4q0Lsp0.txt"));
213 assert!(result.contains("````text\nfirst line\n```inside paste```\nlast line\n````"));
214 }
215}