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 pub async fn import_items(&self, items: ImportExportStream, overwrite: bool) -> Result<ImportStats> {
40 self.storage.import_items(items, overwrite, false).await
41 }
42
43 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 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 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 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 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 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 let mut url = Url::parse(&url).map_err(|err| {
205 tracing::error!("Couldn't parse url: {err}");
206 UserFacingError::HttpInvalidUrl
207 })?;
208
209 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 let client = reqwest::Client::new();
223 let mut req = client.request(method, url);
224
225 for (name, value) in headers {
227 tracing::debug!("Appending '{name}' header");
228 req = req.header(name, value);
229 }
230
231 let res = req.send().await.map_err(|err| {
233 tracing::error!("{err:?}");
234 UserFacingError::HttpRequestFailed(err.to_string())
235 })?;
236
237 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 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 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 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 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 let (gist_id, gist_sha, gist_file) = extract_gist_data(&gist, &gist_config)?;
328
329 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 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 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 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 body.files
388 .remove(gist_file)
389 .ok_or(UserFacingError::GistFileNotFound)?
390 .content
391 } else {
392 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 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#[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 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 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 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 stream::unfold(initial_state, move |mut state| async move {
542 loop {
543 let line: String = match state.lines.next_line().await {
545 Ok(Some(line)) => line,
547 Ok(None) => return None,
549 Err(err) => return Some((Err(AppError::from(err)), state)),
551 };
552 let trimmed_line = line.trim();
553
554 if trimmed_line == "#!intelli-shell" {
556 continue;
557 }
558
559 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 trimmed_line.starts_with('$') {
571 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 state.description_buffer.clear();
599 state.description_paused = false;
600 return Some((item, state));
601 }
602
603 if let Some(comment_content) = get_comment_content(trimmed_line) {
605 if state.description_paused {
606 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 trimmed_line.is_empty() {
616 if !state.description_buffer.is_empty() {
618 state.description_paused = true;
619 }
620 continue;
621 }
622
623 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 loop {
630 if get_comment_content(¤t_trimmed_line).is_some() || current_trimmed_line.is_empty() {
632 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 break;
642 }
643 }
644
645 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 let Some(stripped) = command_segment.strip_suffix('\\') {
656 command_parts.push(stripped.trim().to_string());
657 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 break;
666 }
667 } else {
668 command_parts.push(command_segment.to_string());
670 break;
671 }
672 }
673
674 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 let pre_description = if let Some(inline) = inline_description {
682 inline
683 } else {
684 state.description_buffer.join("\n")
685 };
686 let (alias, mut full_description) = extract_alias(pre_description);
688 if let Some(stripped) = full_description.strip_suffix(':') {
690 full_description = stripped.to_owned();
691 }
692 if !state.tags.is_empty() {
694 full_description = add_tags_to_description(&state.tags, full_description);
695 }
696
697 let command = Command::new(state.category.clone(), state.source.clone(), full_cmd)
699 .with_description(Some(full_description))
700 .with_alias(alias);
701
702 state.description_buffer.clear();
704 state.description_paused = false;
705
706 return Some((Ok(ImportExportItem::Command(command)), state));
708 }
709 })
710}
711
712fn extract_alias(description: String) -> (Option<String>, String) {
716 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 let new_description = ALIAS_RE.replace(&description, |caps: ®ex::Captures| {
725 alias = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str().to_string());
726 ""
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 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, ""); 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 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}