Skip to main content

intelli_shell/service/
import.rs

1use std::{
2    io::{Cursor, ErrorKind},
3    sync::LazyLock,
4};
5
6use color_eyre::{Report, eyre::Context};
7use futures_util::{TryStreamExt, stream};
8use itertools::Itertools;
9use regex::Regex;
10use reqwest::{
11    Url,
12    header::{self, HeaderName, HeaderValue},
13};
14use tokio::{
15    fs::{self, File},
16    io::{AsyncBufReadExt, AsyncRead, BufReader, Lines},
17};
18use tokio_stream::Stream;
19use tokio_util::sync::CancellationToken;
20use tracing::instrument;
21
22use super::IntelliShellService;
23use crate::{
24    cli::{HistorySource, HttpMethod, ImportItemsProcess},
25    config::GistConfig,
26    errors::{AppError, Result, UserFacingError},
27    model::{
28        CATEGORY_USER, Command, ImportExportItem, ImportExportStream, ImportStats, SOURCE_IMPORT, VariableCompletion,
29    },
30    utils::{
31        add_tags_to_description, convert_alt_to_regular,
32        dto::{GIST_README_FILENAME, GIST_README_FILENAME_UPPER, GistDto, ImportExportItemDto},
33        extract_gist_data, github_to_raw, read_history,
34    },
35};
36
37impl IntelliShellService {
38    /// Import commands and completions
39    pub async fn import_items(&self, items: ImportExportStream, overwrite: bool) -> Result<ImportStats> {
40        self.storage.import_items(items, overwrite, false).await
41    }
42
43    /// Returns a list of items to import from a location
44    pub async fn get_items_from_location(
45        &self,
46        args: ImportItemsProcess,
47        gist_config: GistConfig,
48        cancellation_token: CancellationToken,
49    ) -> Result<ImportExportStream> {
50        let ImportItemsProcess {
51            location,
52            file,
53            http,
54            gist,
55            history,
56            ai,
57            filter,
58            dry_run: _,
59            tags,
60            headers,
61            method,
62        } = args;
63
64        // Make sure the tags starts with a hashtag (#)
65        let tags = tags
66            .into_iter()
67            .filter_map(|mut tag| {
68                tag.chars().next().map(|first_char| {
69                    if first_char == '#' {
70                        tag
71                    } else {
72                        tag.insert(0, '#');
73                        tag
74                    }
75                })
76            })
77            .collect::<Vec<_>>();
78
79        // Retrieve the commands from the location
80        let commands = if let Some(history) = history {
81            self.get_history_items(history, filter, tags, ai, cancellation_token)
82                .await?
83        } else if file {
84            if location == "-" {
85                self.get_stdin_items(filter, tags, ai, cancellation_token).await?
86            } else {
87                self.get_file_items(location, filter, tags, ai, cancellation_token)
88                    .await?
89            }
90        } else if http {
91            self.get_http_items(location, headers, method, filter, tags, ai, cancellation_token)
92                .await?
93        } else if gist {
94            self.get_gist_items(location, gist_config, filter, tags, ai, cancellation_token)
95                .await?
96        } else {
97            // Determine which mode based on the location
98            if location == "gist"
99                || location.starts_with("https://gist.github.com")
100                || location.starts_with("https://api.github.com/gists")
101            {
102                self.get_gist_items(location, gist_config, filter, tags, ai, cancellation_token)
103                    .await?
104            } else if location.starts_with("http://") || location.starts_with("https://") {
105                self.get_http_items(location, headers, method, filter, tags, ai, cancellation_token)
106                    .await?
107            } else if location == "-" {
108                self.get_stdin_items(filter, tags, ai, cancellation_token).await?
109            } else {
110                self.get_file_items(location, filter, tags, ai, cancellation_token)
111                    .await?
112            }
113        };
114
115        Ok(commands)
116    }
117
118    #[instrument(skip_all)]
119    async fn get_history_items(
120        &self,
121        history: HistorySource,
122        filter: Option<Regex>,
123        tags: Vec<String>,
124        ai: bool,
125        cancellation_token: CancellationToken,
126    ) -> Result<ImportExportStream> {
127        if let Some(ref filter) = filter {
128            tracing::info!(ai, "Importing commands matching `{filter}` from {history:?} history");
129        } else {
130            tracing::info!(ai, "Importing commands from {history:?} history");
131        }
132        let content = Cursor::new(read_history(history)?);
133        self.extract_and_filter_items(content, filter, tags, ai, cancellation_token)
134            .await
135    }
136
137    #[instrument(skip_all)]
138    async fn get_stdin_items(
139        &self,
140        filter: Option<Regex>,
141        tags: Vec<String>,
142        ai: bool,
143        cancellation_token: CancellationToken,
144    ) -> Result<ImportExportStream> {
145        if let Some(ref filter) = filter {
146            tracing::info!(ai, "Importing commands matching `{filter}` from stdin");
147        } else {
148            tracing::info!(ai, "Importing commands from stdin");
149        }
150        let content = tokio::io::stdin();
151        self.extract_and_filter_items(content, filter, tags, ai, cancellation_token)
152            .await
153    }
154
155    #[instrument(skip_all)]
156    async fn get_file_items(
157        &self,
158        path: String,
159        filter: Option<Regex>,
160        tags: Vec<String>,
161        ai: bool,
162        cancellation_token: CancellationToken,
163    ) -> Result<ImportExportStream> {
164        // Otherwise, check the path to import the file
165        match fs::metadata(&path).await {
166            Ok(m) if m.is_file() => (),
167            Ok(_) => return Err(UserFacingError::ImportLocationNotAFile.into()),
168            Err(err) if err.kind() == ErrorKind::NotFound => return Err(UserFacingError::ImportFileNotFound.into()),
169            Err(err) if err.kind() == ErrorKind::PermissionDenied => {
170                return Err(UserFacingError::FileNotAccessible("read").into());
171            }
172            Err(err) => return Err(Report::from(err).into()),
173        }
174        if let Some(ref filter) = filter {
175            tracing::info!(ai, "Importing commands matching `{filter}` from file: {path}");
176        } else {
177            tracing::info!(ai, "Importing commands from file: {path}");
178        }
179        let content = File::open(path).await.wrap_err("Couldn't open the file")?;
180        self.extract_and_filter_items(content, filter, tags, ai, cancellation_token)
181            .await
182    }
183
184    #[instrument(skip_all)]
185    async fn get_http_items(
186        &self,
187        mut url: String,
188        headers: Vec<(HeaderName, HeaderValue)>,
189        method: HttpMethod,
190        filter: Option<Regex>,
191        tags: Vec<String>,
192        ai: bool,
193        cancellation_token: CancellationToken,
194    ) -> Result<ImportExportStream> {
195        // If the URL is the stdin placeholder, read a line from it
196        if url == "-" {
197            let mut buffer = String::new();
198            std::io::stdin().read_line(&mut buffer)?;
199            url = buffer.trim_end_matches("\n").to_string();
200            tracing::debug!("Read url from stdin: {url}");
201        }
202
203        // Parse the URL
204        let mut url = Url::parse(&url).map_err(|err| {
205            tracing::error!("Couldn't parse url: {err}");
206            UserFacingError::HttpInvalidUrl
207        })?;
208
209        // Try to convert github regular urls to raw
210        if let Some(raw_url) = github_to_raw(&url) {
211            url = raw_url;
212        }
213
214        let method = method.into();
215        if let Some(ref filter) = filter {
216            tracing::info!(ai, "Importing commands matching `{filter}` from http: {method} {url}");
217        } else {
218            tracing::info!(ai, "Importing commands from http: {method} {url}");
219        }
220
221        // Build the request
222        let client = reqwest::Client::new();
223        let mut req = client.request(method, url);
224
225        // Add headers
226        for (name, value) in headers {
227            tracing::debug!("Appending '{name}' header");
228            req = req.header(name, value);
229        }
230
231        // Send the request
232        let res = req.send().await.map_err(|err| {
233            tracing::error!("{err:?}");
234            UserFacingError::HttpRequestFailed(err.to_string())
235        })?;
236
237        // Check the response status
238        if !res.status().is_success() {
239            let status = res.status();
240            let status_str = status.as_str();
241            let body = res.text().await.unwrap_or_default();
242            if let Some(reason) = status.canonical_reason() {
243                tracing::error!("Got response [{status_str}] {reason}:\n{body}");
244                return Err(
245                    UserFacingError::HttpRequestFailed(format!("received {status_str} {reason} response")).into(),
246                );
247            } else {
248                tracing::error!("Got response [{status_str}]:\n{body}");
249                return Err(UserFacingError::HttpRequestFailed(format!("received {status_str} response")).into());
250            }
251        }
252
253        // Check the response content type
254        let mut json = false;
255        if let Some(content_type) = res.headers().get(header::CONTENT_TYPE) {
256            let Ok(content_type) = content_type.to_str() else {
257                return Err(
258                    UserFacingError::HttpRequestFailed(String::from("couldn't read content-type header")).into(),
259                );
260            };
261            if content_type.starts_with("application/json") {
262                json = true;
263            } else if !content_type.starts_with("text") {
264                return Err(
265                    UserFacingError::HttpRequestFailed(format!("unsupported content-type: {content_type}")).into(),
266                );
267            }
268        }
269
270        if json {
271            // Parse the body as a list of commands
272            let items: Vec<ImportExportItemDto> = match res.json().await {
273                Ok(b) => b,
274                Err(err) if err.is_decode() => {
275                    tracing::error!("Couldn't parse api response: {err}");
276                    return Err(UserFacingError::GistRequestFailed(String::from("couldn't parse api response")).into());
277                }
278                Err(err) => {
279                    tracing::error!("{err:?}");
280                    return Err(UserFacingError::GistRequestFailed(err.to_string()).into());
281                }
282            };
283
284            let items = stream::iter(
285                items
286                    .into_iter()
287                    .map(move |item| Ok::<_, AppError>(tag_import_item(ImportExportItem::from(item), &tags))),
288            );
289            let items: ImportExportStream = Box::pin(items);
290            Ok(filter_import_stream(items, filter))
291        } else {
292            let content = Cursor::new(res.text().await.map_err(|err| {
293                tracing::error!("Couldn't read api response: {err}");
294                UserFacingError::HttpRequestFailed(String::from("couldn't read api response"))
295            })?);
296            self.extract_and_filter_items(content, filter, tags, ai, cancellation_token)
297                .await
298        }
299    }
300
301    #[instrument(skip_all)]
302    async fn get_gist_items(
303        &self,
304        mut gist: String,
305        gist_config: GistConfig,
306        filter: Option<Regex>,
307        tags: Vec<String>,
308        ai: bool,
309        cancellation_token: CancellationToken,
310    ) -> Result<ImportExportStream> {
311        // If the gist is the stdin placeholder, read a line from it
312        if gist == "-" {
313            let mut buffer = String::new();
314            std::io::stdin().read_line(&mut buffer)?;
315            gist = buffer.trim_end_matches("\n").to_string();
316            tracing::debug!("Read gist from stdin: {gist}");
317        }
318
319        // For raw gists, import as regular http requests
320        if gist.starts_with("https://gist.githubusercontent.com") {
321            return self
322                .get_http_items(gist, Vec::new(), HttpMethod::GET, filter, tags, ai, cancellation_token)
323                .await;
324        }
325
326        // Retrieve the gist id and optional sha and file
327        let (gist_id, gist_sha, gist_file) = extract_gist_data(&gist, &gist_config)?;
328
329        // Determine the URL based on the presence of sha
330        let url = if let Some(sha) = gist_sha {
331            format!("https://api.github.com/gists/{gist_id}/{sha}")
332        } else {
333            format!("https://api.github.com/gists/{gist_id}")
334        };
335
336        if let Some(ref filter) = filter {
337            tracing::info!(ai, "Importing commands matching `{filter}` from gist: {url}");
338        } else {
339            tracing::info!(ai, "Importing commands from gist: {url}");
340        }
341
342        // Call the API
343        let client = reqwest::Client::new();
344        let res = client
345            .get(url)
346            .header(header::ACCEPT, "application/vnd.github+json")
347            .header(header::USER_AGENT, "intelli-shell")
348            .header("X-GitHub-Api-Version", "2022-11-28")
349            .send()
350            .await
351            .map_err(|err| {
352                tracing::error!("{err:?}");
353                UserFacingError::GistRequestFailed(err.to_string())
354            })?;
355
356        // Check the response status
357        if !res.status().is_success() {
358            let status = res.status();
359            let status_str = status.as_str();
360            let body = res.text().await.unwrap_or_default();
361            if let Some(reason) = status.canonical_reason() {
362                tracing::error!("Got response [{status_str}] {reason}:\n{body}");
363                return Err(
364                    UserFacingError::GistRequestFailed(format!("received {status_str} {reason} response")).into(),
365                );
366            } else {
367                tracing::error!("Got response [{status_str}]:\n{body}");
368                return Err(UserFacingError::GistRequestFailed(format!("received {status_str} response")).into());
369            }
370        }
371
372        // Parse the body as a json
373        let mut body: GistDto = match res.json().await {
374            Ok(b) => b,
375            Err(err) if err.is_decode() => {
376                tracing::error!("Couldn't parse api response: {err}");
377                return Err(UserFacingError::GistRequestFailed(String::from("couldn't parse api response")).into());
378            }
379            Err(err) => {
380                tracing::error!("{err:?}");
381                return Err(UserFacingError::GistRequestFailed(err.to_string()).into());
382            }
383        };
384
385        let full_content = if let Some(ref gist_file) = gist_file {
386            // If there's a file specified, import just it
387            body.files
388                .remove(gist_file)
389                .ok_or(UserFacingError::GistFileNotFound)?
390                .content
391        } else {
392            // Otherwise import all of the files (except the readme)
393            body.files
394                .into_iter()
395                .filter(|(k, _)| k != GIST_README_FILENAME && k != GIST_README_FILENAME_UPPER)
396                .map(|(_, f)| f.content)
397                .join("\n")
398        };
399
400        let content = Cursor::new(full_content);
401        self.extract_and_filter_items(content, filter, tags, ai, cancellation_token)
402            .await
403    }
404
405    /// Extract the commands from the given content, prompting ai or parsing it, and then filters them
406    async fn extract_and_filter_items(
407        &self,
408        content: impl AsyncRead + Unpin + Send + 'static,
409        filter: Option<Regex>,
410        tags: Vec<String>,
411        ai: bool,
412        cancellation_token: CancellationToken,
413    ) -> Result<ImportExportStream> {
414        let stream: ImportExportStream = if ai {
415            let commands = self
416                .prompt_commands_import(content, tags, CATEGORY_USER, SOURCE_IMPORT, cancellation_token)
417                .await?;
418            Box::pin(commands.map_ok(ImportExportItem::Command))
419        } else {
420            Box::pin(parse_import_items(content, tags, CATEGORY_USER, SOURCE_IMPORT))
421        };
422
423        Ok(filter_import_stream(stream, filter))
424    }
425}
426
427fn tag_import_item(item: ImportExportItem, tags: &[String]) -> ImportExportItem {
428    match item {
429        ImportExportItem::Command(mut command) if !tags.is_empty() => {
430            let description = command.description.take().unwrap_or_default();
431            command = command.with_description(Some(add_tags_to_description(tags, description)));
432            ImportExportItem::Command(command)
433        }
434        item => item,
435    }
436}
437
438fn filter_import_stream(stream: ImportExportStream, filter: Option<Regex>) -> ImportExportStream {
439    if let Some(filter) = filter {
440        Box::pin(stream.try_filter(move |item| {
441            let pass = match item {
442                ImportExportItem::Command(command) => command.matches(&filter),
443                ImportExportItem::Completion(_) => true,
444            };
445            async move { pass }
446        }))
447    } else {
448        stream
449    }
450}
451
452/// Lazily parses a stream of text into a [`Stream`] of [`ImportExportItem`].
453///
454/// This function is the primary entry point for parsing command definitions from a file or any other async source.
455/// It operates in a streaming fashion, meaning it reads the input line-by-line without loading the entire content into
456/// memory, making it highly efficient for large files.
457///
458/// # Format Rules
459///
460/// The parser follows a set of rules to interpret the text content:
461///
462/// - **Completions**: Any line starting with `$` is treated as a completion. It must follow the format `$ (root_cmd)
463///   variable: provider`.
464///
465/// - **Commands**: Any line that is not a blank line or a comment is treated as the start of a command.
466///
467/// - **Multi-line Commands**: A command can span multiple lines if a line ends with a backslash (`\`). The parser will
468///   join these lines into a single command string.
469///
470/// - **Descriptions**: A command can have an optional description, specified in one of two ways:
471///   1. **Preceding Comments**: A block of lines starting with `#`, `//`, `::` or `- ` immediately before a command
472///      will be treated as its multi-line description. The comment markers are stripped and the lines are joined with
473///      newlines. Empty comment lines (e.g., `# `) are preserved as blank lines within the description.
474///   2. **Inline Comments** (legacy): An inline description can be provided on the same line, separated by ` ## `. If
475///      both a preceding and an inline description are present, the _inline_ one takes precedence.
476///
477/// - **Aliases**: An optional alias can be extracted from the description by using the format `[alias:your-alias]`.
478///   - The alias tag must be at the very beginning or very end of the entire description block (including multi-line
479///     descriptions).
480///   - The parser extracts the alias and removes it from the final description. For example, `# [alias:a] my command`
481///     results in the alias `a` and the description `my command`.
482///
483/// - **Comments & Spacing**:
484///   - Lines starting with `#`, `//`, `::`, or `- ` (ignoring leading whitespace) are treated as comments.
485///   - Comment lines found _within_ a multi-line command block are ignored and do not become part of the command or its
486///     description.
487///   - Blank lines (i.e., empty or whitespace-only lines) act as separators for description blocks. The description for
488///     a command is the comment block that immediately precedes it.
489///       - A blank line between a comment block and a command is allowed and does not break the association.
490///       - A blank line between two comment blocks makes them distinct; only the latter block will be considered as a
491///         potential description for a subsequent command.
492///
493/// # Errors
494///
495/// The stream will yield an `Err` if an underlying I/O error occurs while reading from the `content` stream.
496#[instrument(skip_all)]
497pub(super) fn parse_import_items(
498    content: impl AsyncRead + Unpin + Send,
499    tags: Vec<String>,
500    category: impl Into<String>,
501    source: impl Into<String>,
502) -> impl Stream<Item = Result<ImportExportItem>> + Send {
503    /// The state of the parser
504    struct ParserState<R: AsyncRead> {
505        category: String,
506        source: String,
507        tags: Vec<String>,
508        lines: Lines<BufReader<R>>,
509        description_buffer: Vec<String>,
510        description_paused: bool,
511    }
512
513    // The initial state for the stream generator
514    let initial_state = ParserState {
515        category: category.into(),
516        source: source.into(),
517        tags,
518        lines: BufReader::new(content).lines(),
519        description_buffer: Vec::new(),
520        description_paused: false,
521    };
522
523    /// Helper to extract the comment content from a trimmed line
524    fn get_comment_content(trimmed_line: &str) -> Option<&str> {
525        if let Some(stripped) = trimmed_line.strip_prefix('#') {
526            return Some(stripped.trim());
527        }
528        if let Some(stripped) = trimmed_line.strip_prefix("//") {
529            return Some(stripped.trim());
530        }
531        if let Some(stripped) = trimmed_line.strip_prefix("- ") {
532            return Some(stripped.trim());
533        }
534        if let Some(stripped) = trimmed_line.strip_prefix("::") {
535            return Some(stripped.trim());
536        }
537        None
538    }
539
540    // Return the commands stream
541    stream::unfold(initial_state, move |mut state| async move {
542        loop {
543            // Read the next line from the input
544            let line: String = match state.lines.next_line().await {
545                // A line is found
546                Ok(Some(line)) => line,
547                // End of the input stream, so we end our command stream
548                Ok(None) => return None,
549                // An I/O error occurred, yield it
550                Err(err) => return Some((Err(AppError::from(err)), state)),
551            };
552            let trimmed_line = line.trim();
553
554            // If the line is the shebang header, skip it
555            if trimmed_line == "#!intelli-shell" {
556                continue;
557            }
558
559            // Skip some line prefixes
560            if trimmed_line.starts_with(">")
561                || trimmed_line.starts_with("```")
562                || trimmed_line.starts_with("%")
563                || trimmed_line.starts_with(";")
564                || trimmed_line.starts_with("@")
565            {
566                continue;
567            }
568
569            // If the line is a completion, parse it
570            if trimmed_line.starts_with('$') {
571                // Regex for completions, with an optional command part
572                // It matches both `$ (cmd) var: provider` and `$ var: provider`
573                static COMPLETION_RE: LazyLock<Regex> = LazyLock::new(|| {
574                    Regex::new(r"^\$\s*(?:\((?P<cmd>[\w./\\:~-]+)\)\s*)?(?P<var>[^:|{}]+):\s*(?P<provider>.+)$")
575                        .unwrap()
576                });
577
578                let item = if let Some(caps) = COMPLETION_RE.captures(trimmed_line) {
579                    let cmd = caps.name("cmd").map_or("", |m| m.as_str()).trim();
580                    let var = caps.name("var").map_or("", |m| m.as_str()).trim();
581                    let provider = caps.name("provider").map_or("", |m| m.as_str()).trim();
582
583                    if var.is_empty() || provider.is_empty() {
584                        Err(UserFacingError::ImportCompletionInvalidFormat(line).into())
585                    } else {
586                        Ok(ImportExportItem::Completion(VariableCompletion::new(
587                            state.source.clone(),
588                            cmd,
589                            var,
590                            provider,
591                        )))
592                    }
593                } else {
594                    Err(UserFacingError::ImportCompletionInvalidFormat(line).into())
595                };
596
597                // In all completion cases, we reset the description buffer and yield the item
598                state.description_buffer.clear();
599                state.description_paused = false;
600                return Some((item, state));
601            }
602
603            // If the line is a comment, accumulate it and continue to the next line
604            if let Some(comment_content) = get_comment_content(trimmed_line) {
605                if state.description_paused {
606                    // If the description was 'paused' by a blank line, a new comment indicates a new description block
607                    state.description_buffer.clear();
608                }
609                state.description_buffer.push(comment_content.to_string());
610                state.description_paused = false;
611                continue;
612            }
613
614            // If the line is blank, it might be a separator between comment blocks or trailing after a description
615            if trimmed_line.is_empty() {
616                // We 'pause' the description accumulation.
617                if !state.description_buffer.is_empty() {
618                    state.description_paused = true;
619                }
620                continue;
621            }
622
623            // Otherwise the line is a command that can potentially span across multiple lines
624            let mut current_trimmed_line = trimmed_line.to_string();
625            let mut command_parts: Vec<String> = Vec::new();
626            let mut inline_description: Option<String> = None;
627
628            // Inner loop to handle multi-line commands
629            loop {
630                // Before processing a line as part of a command
631                if get_comment_content(&current_trimmed_line).is_some() || current_trimmed_line.is_empty() {
632                    // If the line is a comment or a blank line, restart the loop with the next line
633                    if let Some(next_line_res) = state.lines.next_line().await.transpose() {
634                        current_trimmed_line = match next_line_res {
635                            Ok(next_line) => next_line.trim().to_string(),
636                            Err(err) => return Some((Err(AppError::from(err)), state)),
637                        };
638                        continue;
639                    } else {
640                        // End of stream mid-command
641                        break;
642                    }
643                }
644
645                // Check if theres an inline comment after the command
646                let (command_segment, desc) = match current_trimmed_line.split_once(" ## ") {
647                    Some((cmd, desc)) => (cmd, Some(desc.trim().to_string())),
648                    None => (current_trimmed_line.as_str(), None),
649                };
650                if inline_description.is_none() {
651                    inline_description = desc;
652                }
653
654                // If the line ends with the escape char, that means the newline was escaped
655                if let Some(stripped) = command_segment.strip_suffix('\\') {
656                    command_parts.push(stripped.trim().to_string());
657                    // This command spans multiple lines, read the next one and continue with the loop
658                    if let Some(next_line_res) = state.lines.next_line().await.transpose() {
659                        current_trimmed_line = match next_line_res {
660                            Ok(next_line) => next_line.trim().to_string(),
661                            Err(err) => return Some((Err(AppError::from(err)), state)),
662                        };
663                    } else {
664                        // End of stream mid-command
665                        break;
666                    }
667                } else {
668                    // This command consist of a single line, break out of the loop
669                    command_parts.push(command_segment.to_string());
670                    break;
671                }
672            }
673
674            // Setup the cmd
675            let mut full_cmd = command_parts.join(" ");
676            if full_cmd.starts_with('`') && full_cmd.ends_with('`') {
677                full_cmd = full_cmd[1..full_cmd.len() - 1].to_string();
678            }
679            full_cmd = convert_alt_to_regular(&full_cmd);
680            // Setup the description
681            let pre_description = if let Some(inline) = inline_description {
682                inline
683            } else {
684                state.description_buffer.join("\n")
685            };
686            // Extract the alias from the description and clean it up
687            let (alias, mut full_description) = extract_alias(pre_description);
688            // Remove ending colon
689            if let Some(stripped) = full_description.strip_suffix(':') {
690                full_description = stripped.to_owned();
691            }
692            // Include tags if any
693            if !state.tags.is_empty() {
694                full_description = add_tags_to_description(&state.tags, full_description);
695            }
696
697            // Create the command
698            let command = Command::new(state.category.clone(), state.source.clone(), full_cmd)
699                .with_description(Some(full_description))
700                .with_alias(alias);
701
702            // Clear the buffer for the next iteration
703            state.description_buffer.clear();
704            state.description_paused = false;
705
706            // Yield the command and the updated state for the next run
707            return Some((Ok(ImportExportItem::Command(command)), state));
708        }
709    })
710}
711
712/// Extracts an alias `[alias:...]` from the start or end of a description string.
713///
714/// It returns a tuple containing an `Option<String>` for the alias and the cleaned description.
715fn extract_alias(description: String) -> (Option<String>, String) {
716    /// Regex to find an alias at the very start or very end of the string
717    /// Group 2 captures the alias from the start, Group 4 from the end
718    static ALIAS_RE: LazyLock<Regex> =
719        LazyLock::new(|| Regex::new(r"(?s)(?:\A\s*\[alias:([^\]]+)\]\s*)|(?:\s*\[alias:([^\]]+)\]\s*\z)").unwrap());
720
721    let mut alias = None;
722
723    // Use `replace` with a closure to capture the alias while removing the tag
724    let new_description = ALIAS_RE.replace(&description, |caps: &regex::Captures| {
725        alias = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str().to_string());
726        // The matched tag is replaced with an empty string
727        ""
728    });
729
730    (alias, new_description.trim().to_string())
731}
732
733#[cfg(test)]
734mod tests {
735    use futures_util::TryStreamExt;
736
737    use super::*;
738    use crate::utils::dto::{CommandDto, VariableCompletionDto};
739
740    const CMD_1: &str = "cmd number 1";
741    const CMD_2: &str = "cmd number 2";
742    const CMD_3: &str = "cmd number 3";
743
744    #[tokio::test]
745    async fn json_import_applies_tags_and_filters_commands_but_keeps_completions()
746    -> std::result::Result<(), Box<dyn std::error::Error>> {
747        let items = vec![
748            ImportExportItemDto::Command(CommandDto {
749                id: None,
750                alias: None,
751                cmd: "docker ps".to_string(),
752                description: Some("List containers".to_string()),
753            }),
754            ImportExportItemDto::Command(CommandDto {
755                id: None,
756                alias: None,
757                cmd: "git status".to_string(),
758                description: Some("Show worktree".to_string()),
759            }),
760            ImportExportItemDto::Completion(VariableCompletionDto {
761                command: "git".to_string(),
762                variable: "branch".to_string(),
763                provider: "git branch".to_string(),
764            }),
765        ];
766
767        let tags = vec!["#team".to_string()];
768        let items = stream::iter(
769            items
770                .into_iter()
771                .map(move |item| Ok::<_, AppError>(tag_import_item(ImportExportItem::from(item), &tags))),
772        );
773        let items: ImportExportStream = Box::pin(items);
774        let items = filter_import_stream(items, Some(Regex::new("^docker")?))
775            .try_collect::<Vec<_>>()
776            .await
777            .map_err(AppError::into_report)?;
778        assert_eq!(items.len(), 2);
779
780        match &items[0] {
781            ImportExportItem::Command(command) => {
782                assert_eq!(command.cmd, "docker ps");
783                assert_eq!(command.description.as_deref(), Some("List containers #team"));
784            }
785            ImportExportItem::Completion(_) => return Err("Expected a command".into()),
786        }
787        assert!(matches!(items[1], ImportExportItem::Completion(_)));
788        Ok(())
789    }
790
791    const ALIAS_1: &str = "a1";
792    const ALIAS_2: &str = "a2";
793    const ALIAS_3: &str = "a3";
794
795    const DESCRIPTION_1: &str = "Line of a description 1";
796    const DESCRIPTION_2: &str = "Line of a description 2";
797    const DESCRIPTION_3: &str = "Line of a description 3";
798
799    const CMD_MULTI_1: &str = "cmd very long";
800    const CMD_MULTI_2: &str = "that is split across";
801    const CMD_MULTI_3: &str = "multiple lines for readability";
802
803    #[tokio::test]
804    async fn test_parse_import_items_empty_input() {
805        let items = parse_import_items("".as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
806            .try_collect::<Vec<_>>()
807            .await
808            .unwrap();
809        assert!(items.is_empty());
810    }
811
812    #[tokio::test]
813    async fn test_parse_import_items_simple() {
814        let input = format!(
815            r"{CMD_1}
816              {CMD_2}
817              {CMD_3}"
818        );
819        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
820            .try_collect::<Vec<_>>()
821            .await
822            .unwrap();
823
824        assert_eq!(items.len(), 3);
825        assert_eq!(get_command(&items[0]).cmd, CMD_1);
826        assert!(get_command(&items[0]).description.is_none());
827        assert_eq!(get_command(&items[1]).cmd, CMD_2);
828        assert!(get_command(&items[1]).description.is_none());
829        assert_eq!(get_command(&items[2]).cmd, CMD_3);
830        assert!(get_command(&items[2]).description.is_none());
831    }
832
833    #[tokio::test]
834    async fn test_parse_import_items_legacy() {
835        let input = format!(
836            r"{CMD_1} ## {DESCRIPTION_1}
837              {CMD_2} ## {DESCRIPTION_2}
838              {CMD_3} ## {DESCRIPTION_3}"
839        );
840        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
841            .try_collect::<Vec<_>>()
842            .await
843            .unwrap();
844
845        assert_eq!(items.len(), 3);
846        assert_eq!(get_command(&items[0]).cmd, CMD_1);
847        assert_eq!(get_command(&items[0]).description.as_deref(), Some(DESCRIPTION_1));
848        assert_eq!(get_command(&items[1]).cmd, CMD_2);
849        assert_eq!(get_command(&items[1]).description.as_deref(), Some(DESCRIPTION_2));
850        assert_eq!(get_command(&items[2]).cmd, CMD_3);
851        assert_eq!(get_command(&items[2]).description.as_deref(), Some(DESCRIPTION_3));
852    }
853
854    #[tokio::test]
855    async fn test_parse_import_items_sh_style() {
856        let input = format!(
857            r"# {DESCRIPTION_1}
858              {CMD_1}
859
860              # {DESCRIPTION_2}
861              {CMD_2}
862
863              # {DESCRIPTION_3}
864              {CMD_3}"
865        );
866        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
867            .try_collect::<Vec<_>>()
868            .await
869            .unwrap();
870
871        assert_eq!(items.len(), 3);
872        assert_eq!(get_command(&items[0]).cmd, CMD_1);
873        assert_eq!(get_command(&items[0]).description.as_deref(), Some(DESCRIPTION_1));
874        assert_eq!(get_command(&items[1]).cmd, CMD_2);
875        assert_eq!(get_command(&items[1]).description.as_deref(), Some(DESCRIPTION_2));
876        assert_eq!(get_command(&items[2]).cmd, CMD_3);
877        assert_eq!(get_command(&items[2]).description.as_deref(), Some(DESCRIPTION_3));
878    }
879
880    #[tokio::test]
881    async fn test_parse_import_items_tldr_style() {
882        // https://github.com/tldr-pages/tldr/blob/main/CONTRIBUTING.md#markdown-format
883        let input = format!(
884            r"# command-name
885
886              > Short, snappy description.
887              > Preferably one line; two are acceptable if necessary.
888              > More information: <https://url-to-upstream.tld>.
889
890              - {DESCRIPTION_1}:
891              
892              `{CMD_1}`
893
894              - {DESCRIPTION_2}:
895
896              `{CMD_2}`
897
898              - {DESCRIPTION_3}:
899
900              `{CMD_3}`"
901        );
902        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
903            .try_collect::<Vec<_>>()
904            .await
905            .unwrap();
906
907        assert_eq!(items.len(), 3);
908        assert_eq!(get_command(&items[0]).cmd, CMD_1);
909        assert_eq!(get_command(&items[0]).description.as_deref(), Some(DESCRIPTION_1));
910        assert_eq!(get_command(&items[1]).cmd, CMD_2);
911        assert_eq!(get_command(&items[1]).description.as_deref(), Some(DESCRIPTION_2));
912        assert_eq!(get_command(&items[2]).cmd, CMD_3);
913        assert_eq!(get_command(&items[2]).description.as_deref(), Some(DESCRIPTION_3));
914    }
915
916    #[tokio::test]
917    async fn test_parse_import_items_discard_orphan_descriptions() {
918        let input = format!(
919            r"# This is a comment without a command
920
921              # {DESCRIPTION_1}
922              {CMD_1}"
923        );
924        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
925            .try_collect::<Vec<_>>()
926            .await
927            .unwrap();
928
929        assert_eq!(items.len(), 1);
930        assert_eq!(get_command(&items[0]).cmd, CMD_1);
931        assert_eq!(get_command(&items[0]).description.as_deref(), Some(DESCRIPTION_1));
932    }
933
934    #[tokio::test]
935    async fn test_parse_import_items_inline_description_takes_precedence() {
936        let input = format!(
937            r"# {DESCRIPTION_2}
938              {CMD_1} ## {DESCRIPTION_1}"
939        );
940        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
941            .try_collect::<Vec<_>>()
942            .await
943            .unwrap();
944
945        assert_eq!(items.len(), 1);
946        assert_eq!(get_command(&items[0]).cmd, CMD_1);
947        assert_eq!(get_command(&items[0]).description.as_deref(), Some(DESCRIPTION_1));
948    }
949
950    #[tokio::test]
951    async fn test_parse_import_items_multiline_description() {
952        let input = format!(
953            r"# {DESCRIPTION_1}
954              # 
955              # {DESCRIPTION_2}
956              {CMD_1}"
957        );
958        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
959            .try_collect::<Vec<_>>()
960            .await
961            .unwrap();
962
963        assert_eq!(items.len(), 1);
964        let cmd = get_command(&items[0]);
965        assert_eq!(cmd.cmd, CMD_1);
966        assert_eq!(
967            cmd.description.as_ref(),
968            Some(&format!("{DESCRIPTION_1}\n\n{DESCRIPTION_2}"))
969        );
970    }
971
972    #[tokio::test]
973    async fn test_parse_import_items_multiline() {
974        let input = format!(
975            r"# {DESCRIPTION_1}
976              {CMD_MULTI_1} \
977                  # inner comment, not part of the description or command
978                  {CMD_MULTI_2} \ 
979                  {CMD_MULTI_3}"
980        );
981        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
982            .try_collect::<Vec<_>>()
983            .await
984            .unwrap();
985
986        assert_eq!(items.len(), 1);
987        let cmd = get_command(&items[0]);
988        assert_eq!(cmd.cmd, format!("{CMD_MULTI_1} {CMD_MULTI_2} {CMD_MULTI_3}"));
989        assert_eq!(cmd.description.as_deref(), Some(DESCRIPTION_1));
990    }
991
992    #[tokio::test]
993    async fn test_parse_import_items_with_tags_no_description() {
994        let input = CMD_1;
995        let tags = vec!["#test".to_string(), "#tag2".to_string()];
996        let items = parse_import_items(input.as_bytes(), tags, CATEGORY_USER, SOURCE_IMPORT)
997            .try_collect::<Vec<_>>()
998            .await
999            .unwrap();
1000
1001        assert_eq!(items.len(), 1);
1002        let cmd = get_command(&items[0]);
1003        assert_eq!(cmd.cmd, CMD_1);
1004        assert_eq!(cmd.description.as_deref(), Some("#test #tag2"));
1005    }
1006
1007    #[tokio::test]
1008    async fn test_parse_import_items_with_tags_simple_description() {
1009        let input = format!(
1010            r"# {DESCRIPTION_1}
1011              {CMD_1}
1012                    
1013              {CMD_2} ## {DESCRIPTION_2}"
1014        );
1015        let tags = vec!["#test".to_string()];
1016        let items = parse_import_items(input.as_bytes(), tags, CATEGORY_USER, SOURCE_IMPORT)
1017            .try_collect::<Vec<_>>()
1018            .await
1019            .unwrap();
1020
1021        assert_eq!(items.len(), 2);
1022        let cmd0 = get_command(&items[0]);
1023        assert_eq!(cmd0.cmd, CMD_1);
1024        assert_eq!(cmd0.description.as_ref(), Some(&format!("{DESCRIPTION_1} #test")));
1025        let cmd1 = get_command(&items[1]);
1026        assert_eq!(cmd1.cmd, CMD_2);
1027        assert_eq!(cmd1.description.as_ref(), Some(&format!("{DESCRIPTION_2} #test")));
1028    }
1029
1030    #[tokio::test]
1031    async fn test_parse_import_items_with_tags_and_multiline_description() {
1032        let input = format!(
1033            r"# {DESCRIPTION_1}
1034              # {DESCRIPTION_2}
1035              {CMD_1}"
1036        );
1037        let tags = vec!["#test".to_string()];
1038        let items = parse_import_items(input.as_bytes(), tags, CATEGORY_USER, SOURCE_IMPORT)
1039            .try_collect::<Vec<_>>()
1040            .await
1041            .unwrap();
1042
1043        assert_eq!(items.len(), 1);
1044        let cmd = get_command(&items[0]);
1045        assert_eq!(cmd.cmd, CMD_1);
1046        assert_eq!(
1047            cmd.description.as_ref(),
1048            Some(&format!("{DESCRIPTION_1}\n{DESCRIPTION_2}\n#test"))
1049        );
1050    }
1051
1052    #[tokio::test]
1053    async fn test_parse_import_items_skips_existing_tags() {
1054        let input = format!(
1055            r"# {DESCRIPTION_1} #test
1056              {CMD_1}"
1057        );
1058        let tags = vec!["#test".to_string(), "#new".to_string()];
1059        let items = parse_import_items(input.as_bytes(), tags, CATEGORY_USER, SOURCE_IMPORT)
1060            .try_collect::<Vec<_>>()
1061            .await
1062            .unwrap();
1063
1064        assert_eq!(items.len(), 1);
1065        let cmd = get_command(&items[0]);
1066        assert_eq!(cmd.cmd, CMD_1);
1067        assert_eq!(cmd.description.as_ref(), Some(&format!("{DESCRIPTION_1} #test #new")));
1068    }
1069
1070    #[tokio::test]
1071    async fn test_parse_import_items_with_aliases() {
1072        let input = format!(
1073            r"# [alias:{ALIAS_1}] {DESCRIPTION_1}
1074              {CMD_1}
1075
1076              # [alias:{ALIAS_2}] 
1077              # {DESCRIPTION_2}
1078              # {DESCRIPTION_2}
1079              {CMD_2}
1080
1081              # [alias:{ALIAS_3}]
1082              {CMD_3}"
1083        );
1084        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
1085            .try_collect::<Vec<_>>()
1086            .await
1087            .unwrap();
1088
1089        assert_eq!(items.len(), 3);
1090        let cmd0 = get_command(&items[0]);
1091        assert_eq!(cmd0.cmd, CMD_1);
1092        assert_eq!(cmd0.description.as_deref(), Some(DESCRIPTION_1));
1093        assert_eq!(cmd0.alias.as_deref(), Some(ALIAS_1));
1094
1095        let cmd1 = get_command(&items[1]);
1096        assert_eq!(cmd1.cmd, CMD_2);
1097        assert_eq!(
1098            cmd1.description.as_ref(),
1099            Some(&format!("{DESCRIPTION_2}\n{DESCRIPTION_2}"))
1100        );
1101        assert_eq!(cmd1.alias.as_deref(), Some(ALIAS_2));
1102
1103        let cmd2 = get_command(&items[2]);
1104        assert_eq!(cmd2.cmd, CMD_3);
1105        assert!(cmd2.description.is_none());
1106        assert_eq!(cmd2.alias.as_deref(), Some(ALIAS_3));
1107    }
1108
1109    #[tokio::test]
1110    async fn test_parse_import_items_completions() {
1111        let input = r#"
1112            # A command to ensure both types are handled
1113            ls -l ## list files
1114
1115            # Completions
1116            $(git) branch: git branch --all
1117            $ file: ls -F
1118            $ (az) group: az group list --output tsv
1119            $ (my_script.sh) arg: printf 'value'
1120            $ (./my_script.sh) arg2: printf 'value2'
1121            $ (C:\tools\my_script.bat) win_arg: printf 'win'
1122            $ (~/bin/my_script.sh) tilde_arg: printf 'tilde'
1123            "#;
1124
1125        let items = parse_import_items(input.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
1126            .try_collect::<Vec<_>>()
1127            .await
1128            .unwrap();
1129
1130        assert_eq!(items.len(), 8);
1131
1132        let cmd = get_command(&items[0]);
1133        assert_eq!(cmd.cmd, "ls -l");
1134        assert_eq!(cmd.description.as_deref(), Some("list files"));
1135
1136        if let ImportExportItem::Completion(c) = &items[1] {
1137            assert_eq!(c.flat_root_cmd, "git");
1138            assert_eq!(c.flat_variable, "branch");
1139            assert_eq!(c.suggestions_provider, "git branch --all");
1140        } else {
1141            panic!("Expected a Completion at index 1");
1142        }
1143
1144        if let ImportExportItem::Completion(c) = &items[2] {
1145            assert_eq!(c.flat_root_cmd, ""); // Global
1146            assert_eq!(c.flat_variable, "file");
1147            assert_eq!(c.suggestions_provider, "ls -F");
1148        } else {
1149            panic!("Expected a Completion at index 2");
1150        }
1151
1152        if let ImportExportItem::Completion(c) = &items[3] {
1153            assert_eq!(c.flat_root_cmd, "az");
1154            assert_eq!(c.flat_variable, "group");
1155            assert_eq!(c.suggestions_provider, "az group list --output tsv");
1156        } else {
1157            panic!("Expected a Completion at index 3");
1158        }
1159
1160        if let ImportExportItem::Completion(c) = &items[4] {
1161            assert_eq!(c.flat_root_cmd, "myscriptsh");
1162            assert_eq!(c.flat_variable, "arg");
1163            assert_eq!(c.suggestions_provider, "printf 'value'");
1164        } else {
1165            panic!("Expected a Completion at index 4");
1166        }
1167
1168        if let ImportExportItem::Completion(c) = &items[5] {
1169            assert_eq!(c.flat_root_cmd, "myscriptsh");
1170            assert_eq!(c.flat_variable, "arg2");
1171            assert_eq!(c.suggestions_provider, "printf 'value2'");
1172        } else {
1173            panic!("Expected a Completion at index 5");
1174        }
1175
1176        if let ImportExportItem::Completion(c) = &items[6] {
1177            assert_eq!(c.flat_root_cmd, "ctoolsmyscriptbat");
1178            assert_eq!(c.flat_variable, "win_arg");
1179            assert_eq!(c.suggestions_provider, "printf 'win'");
1180        } else {
1181            panic!("Expected a Completion at index 6");
1182        }
1183
1184        if let ImportExportItem::Completion(c) = &items[7] {
1185            assert_eq!(c.flat_root_cmd, "binmyscriptsh");
1186            assert_eq!(c.flat_variable, "tilde_arg");
1187            assert_eq!(c.suggestions_provider, "printf 'tilde'");
1188        } else {
1189            panic!("Expected a Completion at index 7");
1190        }
1191    }
1192
1193    #[tokio::test]
1194    async fn test_parse_import_items_invalid_completion_format() {
1195        let line = "$ invalid completion format";
1196        let result = parse_import_items(line.as_bytes(), Vec::new(), CATEGORY_USER, SOURCE_IMPORT)
1197            .try_collect::<Vec<_>>()
1198            .await;
1199
1200        assert!(result.is_err());
1201        if let Err(err) = result {
1202            assert!(
1203                matches!(err, AppError::UserFacing(UserFacingError::ImportCompletionInvalidFormat(s)) if s == line)
1204            );
1205        }
1206    }
1207
1208    /// Test helper to extract a Command from an ImportExportItem, panicking if it's the wrong variant
1209    fn get_command(item: &ImportExportItem) -> &Command {
1210        match item {
1211            ImportExportItem::Command(command) => command,
1212            ImportExportItem::Completion(_) => panic!("Expected ImportExportItem::Command, found completion"),
1213        }
1214    }
1215}