1use std::env;
2
3use itertools::Itertools;
4use reqwest::Url;
5
6use crate::{
7 config::GistConfig,
8 errors::{Result, UserFacingError},
9};
10
11pub fn get_export_gist_token(gist_config: &GistConfig) -> Result<String> {
21 if let Ok(token) = env::var("GIST_TOKEN")
22 && !token.is_empty()
23 {
24 Ok(token)
25 } else if !gist_config.token.is_empty() {
26 Ok(gist_config.token.clone())
27 } else {
28 Err(UserFacingError::ExportGistMissingToken.into())
29 }
30}
31
32pub fn extract_gist_data(location: &str, gist_config: &GistConfig) -> Result<(String, Option<String>, Option<String>)> {
62 let location = location.trim();
63 if location.is_empty() || location == "gist" {
64 if !gist_config.id.is_empty() {
65 Ok((gist_config.id.clone(), None, None))
66 } else {
67 Err(UserFacingError::GistMissingId.into())
68 }
69 } else {
70 fn is_sha(s: &str) -> bool {
72 s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
73 }
74 fn is_id(s: &str) -> bool {
76 s.chars().all(|c| c.is_ascii_hexdigit())
77 }
78 if let Ok(url) = Url::parse(location) {
80 let host = url.host_str().unwrap_or_default();
81 let segments: Vec<&str> = url.path_segments().map(|s| s.collect()).unwrap_or_default();
82 let gist_data = match host {
83 "gist.github.com" => {
84 if segments.len() < 2 {
86 return Err(UserFacingError::GistInvalidLocation.into());
87 }
88 let id = segments[1].to_string();
89 let mut sha = None;
90 if segments.len() > 2 {
91 if is_sha(segments[2]) {
92 sha = Some(segments[2].to_string());
93 } else {
94 return Err(UserFacingError::GistInvalidLocation.into());
95 }
96 }
97 (id, sha, None)
98 }
99 "gist.githubusercontent.com" => {
100 if segments.len() < 3 || segments[2] != "raw" {
102 return Err(UserFacingError::GistInvalidLocation.into());
103 }
104 let id = segments[1].to_string();
105 let mut sha = None;
106 let mut file = None;
107 if segments.len() > 3 {
108 if is_sha(segments[3]) {
109 sha = Some(segments[3].to_string());
110 if segments.len() > 4 {
111 file = Some(segments[4].to_string());
112 }
113 } else {
114 file = Some(segments[3].to_string());
115 }
116 }
117 (id, sha, file)
118 }
119 "api.github.com" => {
120 if segments.len() < 2 || segments[0] != "gists" {
122 return Err(UserFacingError::GistInvalidLocation.into());
123 }
124 let id = segments[1].to_string();
125 let mut sha = None;
126 if segments.len() > 2 {
127 if is_sha(segments[2]) {
128 sha = Some(segments[2].to_string());
129 } else {
130 return Err(UserFacingError::GistInvalidLocation.into());
131 }
132 }
133 (id, sha, None)
134 }
135 _ => return Err(UserFacingError::GistInvalidLocation.into()),
137 };
138 return Ok(gist_data);
139 }
140
141 let id;
143 let mut sha = None;
144 let mut file = None;
145
146 let parts: Vec<&str> = location.split('/').collect();
147 match parts.len() {
148 1 => {
152 if is_id(parts[0]) {
153 id = parts[0].to_string();
155 } else if !gist_config.id.is_empty() {
156 id = gist_config.id.clone();
158 file = Some(parts[0].to_string());
159 } else {
160 return Err(UserFacingError::GistMissingId.into());
161 }
162 }
163 2 => {
167 if is_id(parts[0]) {
168 id = parts[0].to_string();
169 } else {
170 return Err(UserFacingError::GistInvalidLocation.into());
171 }
172 if is_sha(parts[1]) {
173 sha = Some(parts[1].to_string());
174 } else {
175 file = Some(parts[1].to_string());
176 }
177 }
178 3 => {
181 if is_id(parts[0]) {
182 id = parts[0].to_string();
183 } else {
184 return Err(UserFacingError::GistInvalidLocation.into());
185 }
186 if is_sha(parts[1]) {
187 sha = Some(parts[1].to_string());
188 } else {
189 return Err(UserFacingError::GistInvalidLocation.into());
190 }
191 file = Some(parts[2].to_string());
192 }
193 _ => {
195 return Err(UserFacingError::GistInvalidLocation.into());
196 }
197 }
198
199 Ok((id, sha, file))
200 }
201}
202
203pub fn github_to_raw(url: &Url) -> Option<Url> {
208 if url.host_str() != Some("github.com") {
210 return None;
211 }
212
213 let segments: Vec<&str> = url.path_segments()?.collect();
214
215 if let Some(blob_pos) = segments.iter().position(|&s| s == "blob") {
218 if blob_pos != 2 {
221 return None;
222 }
223
224 let user = segments[0];
225 let repo = segments[1];
226
227 let rest_of_path = &segments[blob_pos + 1..];
229 if rest_of_path.len() < 2 {
230 return None;
231 }
232
233 let raw_url = format!(
235 "https://raw.githubusercontent.com/{}/{}/{}",
236 user,
237 repo,
238 rest_of_path.join("/")
239 );
240
241 Url::parse(&raw_url).ok()
242 } else {
243 None
245 }
246}
247
248pub fn add_tags_to_description(tags: &[String], mut description: String) -> String {
250 let tags = tags.iter().filter(|tag| !description.contains(*tag)).join(" ");
251 if !tags.is_empty() {
252 let multiline = description.contains('\n');
253 if multiline {
254 description += "\n";
255 } else if !description.is_empty() {
256 description += " ";
257 }
258 description += &tags;
259 }
260 description
261}
262
263pub mod dto {
265 use std::collections::HashMap;
266
267 use serde::{Deserialize, Serialize};
268 use uuid::Uuid;
269
270 use crate::model::{CATEGORY_USER, Command, ImportExportItem, SOURCE_IMPORT, VariableCompletion};
271
272 pub const GIST_README_FILENAME: &str = "readme.md";
273 pub const GIST_README_FILENAME_UPPER: &str = "README.md";
274
275 #[derive(Serialize, Deserialize)]
276 #[cfg_attr(debug_assertions, derive(Debug))]
277 #[serde(untagged)]
278 pub enum ImportExportItemDto {
279 Command(CommandDto),
280 Completion(VariableCompletionDto),
281 }
282 impl From<ImportExportItemDto> for ImportExportItem {
283 fn from(value: ImportExportItemDto) -> Self {
284 match value {
285 ImportExportItemDto::Command(dto) => ImportExportItem::Command(dto.into()),
286 ImportExportItemDto::Completion(dto) => ImportExportItem::Completion(dto.into()),
287 }
288 }
289 }
290 impl From<ImportExportItem> for ImportExportItemDto {
291 fn from(value: ImportExportItem) -> Self {
292 match value {
293 ImportExportItem::Command(c) => ImportExportItemDto::Command(c.into()),
294 ImportExportItem::Completion(c) => ImportExportItemDto::Completion(c.into()),
295 }
296 }
297 }
298
299 #[derive(Serialize, Deserialize)]
300 #[cfg_attr(debug_assertions, derive(Debug))]
301 pub struct CommandDto {
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub id: Option<Uuid>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub alias: Option<String>,
306 pub cmd: String,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub description: Option<String>,
309 }
310 impl From<CommandDto> for Command {
311 fn from(value: CommandDto) -> Self {
312 Command::new(CATEGORY_USER, SOURCE_IMPORT, value.cmd)
313 .with_description(value.description)
314 .with_alias(value.alias)
315 }
316 }
317 impl From<Command> for CommandDto {
318 fn from(value: Command) -> Self {
319 CommandDto {
320 id: Some(value.id),
321 alias: value.alias,
322 cmd: value.cmd,
323 description: value.description,
324 }
325 }
326 }
327
328 #[derive(Serialize, Deserialize)]
329 #[cfg_attr(debug_assertions, derive(Debug))]
330 pub struct VariableCompletionDto {
331 pub command: String,
332 pub variable: String,
333 pub provider: String,
334 }
335 impl From<VariableCompletionDto> for VariableCompletion {
336 fn from(value: VariableCompletionDto) -> Self {
337 VariableCompletion::new(SOURCE_IMPORT, value.command, value.variable, value.provider)
338 }
339 }
340 impl From<VariableCompletion> for VariableCompletionDto {
341 fn from(value: VariableCompletion) -> Self {
342 VariableCompletionDto {
343 command: value.flat_root_cmd,
344 variable: value.flat_variable,
345 provider: value.suggestions_provider,
346 }
347 }
348 }
349
350 #[derive(Serialize, Deserialize)]
351 #[cfg_attr(debug_assertions, derive(Debug))]
352 pub struct GistDto {
353 pub files: HashMap<String, GistFileDto>,
354 }
355 #[derive(Serialize, Deserialize)]
356 #[cfg_attr(debug_assertions, derive(Debug))]
357 pub struct GistFileDto {
358 pub content: String,
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 const TEST_GIST_ID: &str = "b3a462e23db5c99d1f3f4abf0dae5bd8";
367 const TEST_GIST_SHA: &str = "330286d6e41f8ae0a5b4ddc3e01d5521b87a15ca";
368 const TEST_GIST_FILE: &str = "my_commands.sh";
369
370 #[test]
371 fn test_extract_gist_data_config() {
372 let (id, sha, file) = extract_gist_data(
373 "gist",
374 &GistConfig {
375 id: String::from(TEST_GIST_ID),
376 ..Default::default()
377 },
378 )
379 .unwrap();
380 assert_eq!(id, TEST_GIST_ID);
381 assert_eq!(sha, None);
382 assert_eq!(file, None);
383 }
384
385 #[test]
386 fn test_extract_gist_data() {
387 let location = format!("https://gist.github.com/username/{TEST_GIST_ID}");
388 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
389 assert_eq!(id, TEST_GIST_ID);
390 assert_eq!(sha, None);
391 assert_eq!(file, None);
392 }
393
394 #[test]
395 fn test_extract_gist_data_with_sha() {
396 let location = format!("https://gist.github.com/username/{TEST_GIST_ID}/{TEST_GIST_SHA}");
397 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
398 assert_eq!(id, TEST_GIST_ID);
399 assert_eq!(sha.as_deref(), Some(TEST_GIST_SHA));
400 assert_eq!(file, None);
401 }
402
403 #[test]
404 fn test_extract_gist_data_raw() {
405 let location = format!("https://gist.githubusercontent.com/username/{TEST_GIST_ID}/raw");
406 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
407 assert_eq!(id, TEST_GIST_ID);
408 assert_eq!(sha, None);
409 assert_eq!(file, None);
410 }
411
412 #[test]
413 fn test_extract_gist_data_raw_with_file() {
414 let location = format!("https://gist.githubusercontent.com/username/{TEST_GIST_ID}/raw/{TEST_GIST_FILE}");
415 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
416 assert_eq!(id, TEST_GIST_ID);
417 assert_eq!(sha, None);
418 assert_eq!(file.as_deref(), Some(TEST_GIST_FILE));
419 }
420
421 #[test]
422 fn test_extract_gist_data_raw_with_sha() {
423 let location = format!("https://gist.githubusercontent.com/username/{TEST_GIST_ID}/raw/{TEST_GIST_SHA}");
424 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
425 assert_eq!(id, TEST_GIST_ID);
426 assert_eq!(sha.as_deref(), Some(TEST_GIST_SHA));
427 assert_eq!(file, None);
428 }
429
430 #[test]
431 fn test_extract_gist_data_raw_with_sha_and_file() {
432 let location =
433 format!("https://gist.githubusercontent.com/username/{TEST_GIST_ID}/raw/{TEST_GIST_SHA}/{TEST_GIST_FILE}");
434 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
435 assert_eq!(id, TEST_GIST_ID);
436 assert_eq!(sha.as_deref(), Some(TEST_GIST_SHA));
437 assert_eq!(file.as_deref(), Some(TEST_GIST_FILE));
438 }
439
440 #[test]
441 fn test_extract_gist_data_api() {
442 let location = format!("https://api.github.com/gists/{TEST_GIST_ID}");
443 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
444 assert_eq!(id, TEST_GIST_ID);
445 assert_eq!(sha, None);
446 assert_eq!(file, None);
447 }
448
449 #[test]
450 fn test_extract_gist_data_api_with_sha() {
451 let location = format!("https://api.github.com/gists/{TEST_GIST_ID}/{TEST_GIST_SHA}");
452 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
453 assert_eq!(id, TEST_GIST_ID);
454 assert_eq!(sha.as_deref(), Some(TEST_GIST_SHA));
455 assert_eq!(file, None);
456 }
457
458 #[test]
459 fn test_extract_gist_data_shorthand_file() {
460 let (id, sha, file) = extract_gist_data(
461 TEST_GIST_FILE,
462 &GistConfig {
463 id: String::from(TEST_GIST_ID),
464 ..Default::default()
465 },
466 )
467 .unwrap();
468 assert_eq!(id, TEST_GIST_ID);
469 assert_eq!(sha, None);
470 assert_eq!(file.as_deref(), Some(TEST_GIST_FILE));
471 }
472
473 #[test]
474 fn test_extract_gist_data_shorthand_id() {
475 let (id, sha, file) = extract_gist_data(TEST_GIST_ID, &GistConfig::default()).unwrap();
476 assert_eq!(id, TEST_GIST_ID);
477 assert_eq!(sha, None);
478 assert_eq!(file, None);
479 }
480
481 #[test]
482 fn test_extract_gist_data_shorthand_id_and_file() {
483 let location = format!("{TEST_GIST_ID}/{TEST_GIST_FILE}");
484 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
485 assert_eq!(id, TEST_GIST_ID);
486 assert_eq!(sha, None);
487 assert_eq!(file.as_deref(), Some(TEST_GIST_FILE));
488 }
489
490 #[test]
491 fn test_extract_gist_data_shorthand_id_and_sha() {
492 let location = format!("{TEST_GIST_ID}/{TEST_GIST_SHA}");
493 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
494 assert_eq!(id, TEST_GIST_ID);
495 assert_eq!(sha.as_deref(), Some(TEST_GIST_SHA));
496 assert_eq!(file, None);
497 }
498
499 #[test]
500 fn test_extract_gist_data_shorthand_id_and_sha_and_file() {
501 let location = format!("{TEST_GIST_ID}/{TEST_GIST_SHA}/{TEST_GIST_FILE}");
502 let (id, sha, file) = extract_gist_data(&location, &GistConfig::default()).unwrap();
503 assert_eq!(id, TEST_GIST_ID);
504 assert_eq!(sha.as_deref(), Some(TEST_GIST_SHA));
505 assert_eq!(file.as_deref(), Some(TEST_GIST_FILE));
506 }
507
508 #[test]
509 fn test_github_to_url_valid() {
510 let github_url = Url::parse("https://github.com/rust-lang/rust/blob/master/README.md").unwrap();
511 let expected = Url::parse("https://raw.githubusercontent.com/rust-lang/rust/master/README.md").unwrap();
512 assert_eq!(github_to_raw(&github_url), Some(expected));
513 }
514
515 #[test]
516 fn test_github_to_url_with_subdirectories() {
517 let github_url = Url::parse("https://github.com/user/repo/blob/main/src/app/main.rs").unwrap();
518 let expected = Url::parse("https://raw.githubusercontent.com/user/repo/main/src/app/main.rs").unwrap();
519 assert_eq!(github_to_raw(&github_url), Some(expected));
520 }
521
522 #[test]
523 fn test_github_to_url_with_commit_hash() {
524 let github_url = Url::parse("https://github.com/user/repo/blob/a1b2c3d4e5f6/path/to/file.txt").unwrap();
525 let expected = Url::parse("https://raw.githubusercontent.com/user/repo/a1b2c3d4e5f6/path/to/file.txt").unwrap();
526 assert_eq!(github_to_raw(&github_url), Some(expected));
527 }
528
529 #[test]
530 fn test_github_to_url_invalid_domain() {
531 let url = Url::parse("https://gitlab.com/user/repo/blob/main/file.txt").unwrap();
532 assert_eq!(github_to_raw(&url), None);
533 }
534
535 #[test]
536 fn test_github_to_url_not_a_blob() {
537 let url = Url::parse("https://github.com/user/repo/tree/main/src").unwrap();
538 assert_eq!(github_to_raw(&url), None);
539 }
540
541 #[test]
542 fn test_github_to_url_root_repo() {
543 let url = Url::parse("https://github.com/user/repo").unwrap();
544 assert_eq!(github_to_raw(&url), None);
545 }
546
547 #[test]
548 fn test_github_to_url_with_query_params_and_fragment() {
549 let github_url = Url::parse("https://github.com/user/repo/blob/main/file.txt?raw=true#L10").unwrap();
550 let expected = Url::parse("https://raw.githubusercontent.com/user/repo/main/file.txt").unwrap();
551 assert_eq!(github_to_raw(&github_url), Some(expected));
552 }
553
554 #[test]
555 fn test_github_to_url_with_insufficient_segments() {
556 let url = Url::parse("https://github.com/user/repo/blob/").unwrap();
557 assert_eq!(github_to_raw(&url), None);
558 }
559}