intelli_shell/utils/
import_export.rs

1use std::env;
2
3use itertools::Itertools;
4use reqwest::Url;
5
6use crate::{
7    config::GistConfig,
8    errors::{Result, UserFacingError},
9};
10
11/// Retrieves a GitHub personal access token for gist, checking configuration and environment variables.
12///
13/// This function attempts to find a token by searching in the following locations, in order of
14/// precedence:
15///
16/// 1. The `GIST_TOKEN` environment variable
17/// 2. The `token` field of the provided `gist_config` object
18///
19/// If a token is not found in any of these locations, the function will return an error.
20pub 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
32/// Parses a Gist location string to extract its ID, and optional SHA and filename.
33///
34/// This function is highly flexible and can interpret several Gist location formats, including full URLs, shorthand
35/// notations, and special placeholder values.
36///
37/// ### Placeholder Behavior
38///
39/// If the `location` string is a placeholder (`"gist"`, or an empty/whitespace string), the function will attempt
40/// to use the `id` from the provided `gist_config` as a fallback. If `gist_config` is `None` in this case, it will
41/// return an error.
42///
43/// ### Supported URL Formats
44///
45/// - `https://gist.github.com/{user}/{id}`
46/// - `https://gist.github.com/{user}/{id}/{sha}`
47/// - `https://gist.githubusercontent.com/{user}/{id}/raw`
48/// - `https://gist.githubusercontent.com/{user}/{id}/raw/{file}`
49/// - `https://gist.githubusercontent.com/{user}/{id}/raw/{sha}`
50/// - `https://gist.githubusercontent.com/{user}/{id}/raw/{sha}/{file}`
51/// - `https://api.github.com/gists/{id}`
52/// - `https://api.github.com/gists/{id}/{sha}`
53///
54/// ### Supported Shorthand Formats
55///
56/// - `{file}` (with the id from the config)
57/// - `{id}`
58/// - `{id}/{file}`
59/// - `{id}/{sha}`
60/// - `{id}/{sha}/{file}`
61pub 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        /// Helper function to check if a string is a commit sha
71        fn is_sha(s: &str) -> bool {
72            s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
73        }
74        /// Helper function to check if a string is a gist id
75        fn is_id(s: &str) -> bool {
76            s.chars().all(|c| c.is_ascii_hexdigit())
77        }
78        // First, attempt to parse the location as a full URL
79        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                    // Handles: https://gist.github.com/{user}/{id}/{sha?}
85                    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                    // Handles: https://gist.githubusercontent.com/{user}/{id}/raw/{sha?}/{file?}
101                    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                    // Handles: https://api.github.com/gists/{id}/{sha?}
121                    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                // Any other host is considered an invalid location
136                _ => return Err(UserFacingError::GistInvalidLocation.into()),
137            };
138            return Ok(gist_data);
139        }
140
141        // If it's not a valid URL, treat it as a shorthand format
142        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            // Handles:
149            // - {file} (with id from config)
150            // - {id}
151            1 => {
152                if is_id(parts[0]) {
153                    // Looks like an id
154                    id = parts[0].to_string();
155                } else if !gist_config.id.is_empty() {
156                    // If it doesn't look like an id, treat it like a file and pick the id from the config
157                    id = gist_config.id.clone();
158                    file = Some(parts[0].to_string());
159                } else {
160                    return Err(UserFacingError::GistMissingId.into());
161                }
162            }
163            // Handles:
164            // - {id}/{file}
165            // - {id}/{sha}
166            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            // Handles:
179            // - {id}/{sha}/{file}
180            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            // Too many segments
194            _ => {
195                return Err(UserFacingError::GistInvalidLocation.into());
196            }
197        }
198
199        Ok((id, sha, file))
200    }
201}
202
203/// Converts a GitHub file URL to its raw.githubusercontent.com equivalent.
204///
205/// It handles URLs in the format: `https://github.com/{user}/{repo}/blob/{branch_or_commit}/{file_path}`.
206/// It correctly ignores any query parameters or fragments in the original URL.
207pub fn github_to_raw(url: &Url) -> Option<Url> {
208    // Skip non-github urls
209    if url.host_str() != Some("github.com") {
210        return None;
211    }
212
213    let segments: Vec<&str> = url.path_segments()?.collect();
214
215    // A valid URL must have at least 4 parts: user, repo, "blob", and a branch/commit name
216    // We search for the "blob" segment, which separates the repo info from the file path
217    if let Some(blob_pos) = segments.iter().position(|&s| s == "blob") {
218        // The expected structure is /<user>/<repo>/blob/...
219        // So, "blob" must be the third segment (index 2)
220        if blob_pos != 2 {
221            return None;
222        }
223
224        let user = segments[0];
225        let repo = segments[1];
226
227        // The parts after "blob" are the branch/commit and the file path
228        let rest_of_path = &segments[blob_pos + 1..];
229        if rest_of_path.len() < 2 {
230            return None;
231        }
232
233        // Assemble the new raw content URL
234        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        // If "blob" is not in the path, it's not a URL we can convert (e.g., it might be a /tree/ URL)
244        None
245    }
246}
247
248/// Adds tags to a description, only those not already present will be added
249pub 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
263/// Data Transfer Objects when importing and exporting
264pub 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}