intelli_shell/model/
import_export.rs

1use std::{fmt, pin::Pin};
2
3use futures_util::Stream;
4
5use super::{Command, VariableCompletion};
6use crate::{config::Theme, errors::Result, format_error, format_msg, process::ProcessOutput};
7
8/// A unified data model to handle both commands and completions in a single stream
9#[derive(Clone)]
10pub enum ImportExportItem {
11    Command(Command),
12    Completion(VariableCompletion),
13}
14
15impl fmt::Display for ImportExportItem {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            ImportExportItem::Command(c) => c.fmt(f),
19            ImportExportItem::Completion(c) => c.fmt(f),
20        }
21    }
22}
23
24/// Stream of results of [`ImportExportItem`]
25pub type ImportExportStream = Pin<Box<dyn Stream<Item = Result<ImportExportItem>> + Send>>;
26
27/// Statistics collected when importing
28#[derive(Default)]
29pub struct ImportStats {
30    pub commands_imported: u64,
31    pub commands_updated: u64,
32    pub commands_skipped: u64,
33    pub completions_imported: u64,
34    pub completions_updated: u64,
35    pub completions_skipped: u64,
36}
37
38/// Statistics collected when exporting
39#[derive(Default)]
40pub struct ExportStats {
41    pub commands_exported: u64,
42    pub completions_exported: u64,
43    pub stdout: Option<String>,
44}
45
46impl ImportStats {
47    /// Converts these statistics into a [ProcessOutput] with proper message
48    pub fn into_output(self, theme: &Theme) -> ProcessOutput {
49        let ImportStats {
50            commands_imported,
51            commands_updated,
52            commands_skipped,
53            completions_imported,
54            completions_updated,
55            completions_skipped,
56        } = self;
57
58        // Check if any operations were performed at all
59        let total_actions = commands_imported
60            + commands_updated
61            + commands_skipped
62            + completions_imported
63            + completions_updated
64            + completions_skipped;
65
66        // If no actions occurred, it implies no items were found to process
67        if total_actions == 0 {
68            return ProcessOutput::fail().stderr(format_error!(theme, "No commands or completions were found"));
69        }
70
71        // Determine if any actual changes (imports or updates) were made
72        let was_changed =
73            commands_imported > 0 || commands_updated > 0 || completions_imported > 0 || completions_updated > 0;
74
75        let message = if was_changed {
76            // Build message parts for each action type to combine them naturally
77            let mut imported_parts = Vec::with_capacity(2);
78            if commands_imported > 0 {
79                imported_parts.push(format!(
80                    "{} new command{}",
81                    commands_imported,
82                    plural_s(commands_imported)
83                ));
84            }
85            if completions_imported > 0 {
86                imported_parts.push(format!(
87                    "{} new completion{}",
88                    completions_imported,
89                    plural_s(completions_imported),
90                ));
91            }
92
93            let mut updated_parts = Vec::with_capacity(2);
94            if commands_updated > 0 {
95                updated_parts.push(format!("{} command{}", commands_updated, plural_s(commands_updated)));
96            }
97            if completions_updated > 0 {
98                updated_parts.push(format!(
99                    "{} completion{}",
100                    completions_updated,
101                    plural_s(completions_updated)
102                ));
103            }
104
105            let mut skipped_parts = Vec::with_capacity(2);
106            if commands_skipped > 0 {
107                skipped_parts.push(format!("{} command{}", commands_skipped, plural_s(commands_skipped)));
108            }
109            if completions_skipped > 0 {
110                skipped_parts.push(format!(
111                    "{} completion{}",
112                    completions_skipped,
113                    plural_s(completions_skipped)
114                ));
115            }
116
117            // The primary message focuses on imports first, then updates
118            let main_msg;
119            let mut secondary_msg_parts = Vec::with_capacity(2);
120
121            if !imported_parts.is_empty() {
122                main_msg = format!("Imported {}", imported_parts.join(" and "));
123                // If there were imports, updates are secondary information.
124                if !updated_parts.is_empty() {
125                    secondary_msg_parts.push(format!("{} updated", updated_parts.join(" and ")));
126                }
127            } else {
128                // If there were no imports, updates become the primary message.
129                main_msg = format!("Updated {}", updated_parts.join(" and "));
130            }
131
132            // Skipped items are always secondary information.
133            if !skipped_parts.is_empty() {
134                secondary_msg_parts.push(format!("{} already existed", skipped_parts.join(" and ")));
135            }
136
137            // Combine the main message with the styled, parenthesized secondary message.
138            let secondary_msg = if !secondary_msg_parts.is_empty() {
139                format!(" ({})", secondary_msg_parts.join("; "))
140            } else {
141                String::new()
142            };
143
144            format_msg!(theme, "{main_msg}{}", theme.secondary.apply(secondary_msg))
145        } else {
146            // This message is for when only skips occurred
147            let mut skipped_parts = Vec::with_capacity(2);
148            if commands_skipped > 0 {
149                skipped_parts.push(format!("{} command{}", commands_skipped, plural_s(commands_skipped)));
150            }
151            if completions_skipped > 0 {
152                skipped_parts.push(format!(
153                    "{} completion{}",
154                    completions_skipped,
155                    plural_s(completions_skipped),
156                ));
157            }
158            format!("No new changes; {} already existed", skipped_parts.join(" and "))
159        };
160
161        ProcessOutput::success().stderr(message)
162    }
163}
164
165impl ExportStats {
166    /// Converts these statistics into a [ProcessOutput] with a proper message
167    pub fn into_output(self, theme: &Theme) -> ProcessOutput {
168        let ExportStats {
169            commands_exported,
170            completions_exported,
171            stdout,
172        } = self;
173
174        // If nothing was exported, return a failure message
175        if commands_exported == 0 && completions_exported == 0 {
176            return ProcessOutput::fail().stderr(format_error!(theme, "No commands or completions to export"));
177        }
178
179        // Build a message describing what was exported
180        let mut parts = Vec::with_capacity(2);
181        if commands_exported > 0 {
182            parts.push(format!("{} command{}", commands_exported, plural_s(commands_exported)));
183        }
184        if completions_exported > 0 {
185            parts.push(format!(
186                "{} completion{}",
187                completions_exported,
188                plural_s(completions_exported)
189            ));
190        }
191
192        let summary = parts.join(" and ");
193        let stderr_msg = format_msg!(theme, "Exported {summary}");
194
195        // Create the base success output
196        let mut output = ProcessOutput::success().stderr(stderr_msg);
197
198        // If there's content for stdout, attach it to the output
199        if let Some(stdout_content) = stdout {
200            output = output.stdout(stdout_content);
201        }
202
203        output
204    }
205}
206
207/// A simple helper to return "s" for pluralization if the count is not 1.
208fn plural_s(count: u64) -> &'static str {
209    if count == 1 { "" } else { "s" }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::config::Theme;
216
217    #[test]
218    fn test_import_stats_into_output_no_actions() {
219        let stats = ImportStats::default();
220        let theme = Theme::default();
221        let output = stats.into_output(&theme);
222
223        if let ProcessOutput::Output(info) = output {
224            assert!(info.failed);
225            assert!(info.stdout.is_none());
226            assert_eq!(
227                strip_ansi(info.stderr.as_deref().unwrap()),
228                "[Error] No commands or completions were found",
229            );
230        } else {
231            panic!("Expected ProcessOutput::Output variant");
232        }
233    }
234
235    #[test]
236    fn test_import_stats_into_output_only_skipped() {
237        let stats = ImportStats {
238            commands_skipped: 5,
239            completions_skipped: 2,
240            ..Default::default()
241        };
242        let theme = Theme::default();
243        let output = stats.into_output(&theme);
244
245        if let ProcessOutput::Output(info) = output {
246            assert!(!info.failed);
247            assert!(info.stdout.is_none());
248            assert_eq!(
249                strip_ansi(info.stderr.as_deref().unwrap()),
250                "No new changes; 5 commands and 2 completions already existed",
251            );
252        } else {
253            panic!("Expected ProcessOutput::Output variant");
254        }
255    }
256
257    #[test]
258    fn test_import_stats_into_output_only_skipped_singular() {
259        let stats = ImportStats {
260            commands_skipped: 1,
261            completions_skipped: 1,
262            ..Default::default()
263        };
264        let theme = Theme::default();
265        let output = stats.into_output(&theme);
266
267        if let ProcessOutput::Output(info) = output {
268            assert!(!info.failed);
269            assert!(info.stdout.is_none());
270            assert_eq!(
271                strip_ansi(info.stderr.as_deref().unwrap()),
272                "No new changes; 1 command and 1 completion already existed",
273            );
274        } else {
275            panic!("Expected ProcessOutput::Output variant");
276        }
277    }
278
279    #[test]
280    fn test_import_stats_into_output_only_imports() {
281        let stats = ImportStats {
282            commands_imported: 1,
283            completions_imported: 1,
284            ..Default::default()
285        };
286        let theme = Theme::default();
287        let output = stats.into_output(&theme);
288
289        if let ProcessOutput::Output(info) = output {
290            assert!(!info.failed);
291            assert!(info.stdout.is_none());
292            assert_eq!(
293                strip_ansi(info.stderr.as_deref().unwrap()),
294                "-> Imported 1 new command and 1 new completion",
295            );
296        } else {
297            panic!("Expected ProcessOutput::Output variant");
298        }
299    }
300
301    #[test]
302    fn test_import_stats_into_output_only_updates() {
303        let stats = ImportStats {
304            commands_updated: 10,
305            completions_updated: 1,
306            ..Default::default()
307        };
308        let theme = Theme::default();
309        let output = stats.into_output(&theme);
310
311        if let ProcessOutput::Output(info) = output {
312            assert!(!info.failed);
313            assert!(info.stdout.is_none());
314            assert_eq!(
315                strip_ansi(info.stderr.as_deref().unwrap()),
316                "-> Updated 10 commands and 1 completion",
317            );
318        } else {
319            panic!("Expected ProcessOutput::Output variant");
320        }
321    }
322
323    #[test]
324    fn test_import_stats_into_output_imports_and_skipped() {
325        let stats = ImportStats {
326            commands_imported: 3,
327            commands_skipped: 2,
328            completions_imported: 4,
329            completions_skipped: 1,
330            ..Default::default()
331        };
332        let theme = Theme::default();
333        let output = stats.into_output(&theme);
334
335        if let ProcessOutput::Output(info) = output {
336            assert!(!info.failed);
337            assert!(info.stdout.is_none());
338            assert_eq!(
339                strip_ansi(info.stderr.as_deref().unwrap()),
340                "-> Imported 3 new commands and 4 new completions (2 commands and 1 completion already existed)",
341            );
342        } else {
343            panic!("Expected ProcessOutput::Output variant");
344        }
345    }
346
347    #[test]
348    fn test_import_stats_into_output_imports_cmds_skipped_completions() {
349        let stats = ImportStats {
350            commands_imported: 5,
351            completions_skipped: 3,
352            ..Default::default()
353        };
354        let theme = Theme::default();
355        let output = stats.into_output(&theme);
356
357        if let ProcessOutput::Output(info) = output {
358            assert!(!info.failed);
359            assert!(info.stdout.is_none());
360            assert_eq!(
361                strip_ansi(info.stderr.as_deref().unwrap()),
362                "-> Imported 5 new commands (3 completions already existed)",
363            );
364        } else {
365            panic!("Expected ProcessOutput::Output variant");
366        }
367    }
368
369    #[test]
370    fn test_export_stats_into_output_no_actions() {
371        let stats = ExportStats::default();
372        let theme = Theme::default();
373        let output = stats.into_output(&theme);
374
375        if let ProcessOutput::Output(info) = output {
376            assert!(info.failed);
377            assert!(info.stdout.is_none());
378            assert_eq!(
379                strip_ansi(info.stderr.as_deref().unwrap()),
380                "[Error] No commands or completions to export"
381            );
382        } else {
383            panic!("Expected ProcessOutput::Output variant");
384        }
385    }
386
387    #[test]
388    fn test_export_stats_into_output_only_commands_singular() {
389        let stats = ExportStats {
390            commands_exported: 1,
391            ..Default::default()
392        };
393        let theme = Theme::default();
394        let output = stats.into_output(&theme);
395
396        if let ProcessOutput::Output(info) = output {
397            assert!(!info.failed);
398            assert!(info.stdout.is_none());
399            assert_eq!(strip_ansi(info.stderr.as_deref().unwrap()), "-> Exported 1 command");
400        } else {
401            panic!("Expected ProcessOutput::Output variant");
402        }
403    }
404
405    #[test]
406    fn test_export_stats_into_output_only_completions_plural() {
407        let stats = ExportStats {
408            completions_exported: 10,
409            ..Default::default()
410        };
411        let theme = Theme::default();
412        let output = stats.into_output(&theme);
413
414        if let ProcessOutput::Output(info) = output {
415            assert!(!info.failed);
416            assert!(info.stdout.is_none());
417            assert_eq!(
418                strip_ansi(info.stderr.as_deref().unwrap()),
419                "-> Exported 10 completions"
420            );
421        } else {
422            panic!("Expected ProcessOutput::Output variant");
423        }
424    }
425
426    #[test]
427    fn test_export_stats_into_output_both_commands_and_completions() {
428        let stats = ExportStats {
429            commands_exported: 5,
430            completions_exported: 8,
431            ..Default::default()
432        };
433        let theme = Theme::default();
434        let output = stats.into_output(&theme);
435
436        if let ProcessOutput::Output(info) = output {
437            assert!(!info.failed);
438            assert!(info.stdout.is_none());
439            assert_eq!(
440                strip_ansi(info.stderr.as_deref().unwrap()),
441                "-> Exported 5 commands and 8 completions"
442            );
443        } else {
444            panic!("Expected ProcessOutput::Output variant");
445        }
446    }
447
448    // Helper to strip ANSI color codes for cleaner assertions
449    fn strip_ansi(s: &str) -> String {
450        String::from_utf8(strip_ansi_escapes::strip(s.as_bytes())).unwrap()
451    }
452}