git_x/core/
interactive.rs

1use crate::{GitXError, Result};
2use dialoguer::{Confirm, FuzzySelect, Input};
3use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
4
5/// Interactive utilities with fuzzy search capabilities
6pub struct Interactive;
7
8impl Interactive {
9    /// Check if we're running in an interactive environment
10    pub fn is_interactive() -> bool {
11        // Check for any test-related environment variables or conditions
12        if std::env::var("CARGO_TARGET_TMPDIR").is_ok()
13            || std::env::var("CI").is_ok()
14            || std::env::var("GITHUB_ACTIONS").is_ok()
15            || std::env::var("GIT_X_NON_INTERACTIVE").is_ok()
16            || !atty::is(atty::Stream::Stdin)
17        {
18            return false;
19        }
20
21        true
22    }
23
24    /// Show a fuzzy-searchable selection menu
25    pub fn fuzzy_select<T: AsRef<str> + Clone + ToString>(
26        items: &[T],
27        prompt: &str,
28        default: Option<usize>,
29    ) -> Result<T> {
30        let selection = FuzzySelect::new()
31            .with_prompt(prompt)
32            .items(items)
33            .default(default.unwrap_or(0))
34            .interact()
35            .map_err(|e| GitXError::GitCommand(format!("Selection cancelled: {e}")))?;
36
37        Ok(items[selection].clone())
38    }
39
40    /// Show an enhanced branch picker with fuzzy search
41    pub fn branch_picker(branches: &[String], prompt: Option<&str>) -> Result<String> {
42        if branches.is_empty() {
43            return Err(GitXError::GitCommand("No branches available".to_string()));
44        }
45
46        let formatted_items: Vec<String> = branches
47            .iter()
48            .enumerate()
49            .map(|(i, branch)| {
50                let prefix = if i == 0 { "🌟 " } else { "📁 " };
51                format!("{prefix}{branch}")
52            })
53            .collect();
54
55        let prompt_text = prompt.unwrap_or("Select a branch");
56        let selection = FuzzySelect::new()
57            .with_prompt(prompt_text)
58            .items(&formatted_items)
59            .default(0)
60            .interact()
61            .map_err(|e| GitXError::GitCommand(format!("Selection cancelled: {e}")))?;
62
63        Ok(branches[selection].clone())
64    }
65
66    /// Get text input with validation
67    pub fn text_input(
68        prompt: &str,
69        default: Option<&str>,
70        validator: Option<fn(&str) -> Result<()>>,
71    ) -> Result<String> {
72        let mut input_builder = Input::<String>::new().with_prompt(prompt);
73
74        if let Some(default_val) = default {
75            input_builder = input_builder.default(default_val.to_string());
76        }
77
78        let input = input_builder
79            .interact_text()
80            .map_err(|e| GitXError::GitCommand(format!("Input cancelled: {e}")))?;
81
82        // Apply validation if provided
83        if let Some(validate_fn) = validator {
84            validate_fn(&input)?;
85        }
86
87        Ok(input)
88    }
89
90    /// Show a confirmation dialog
91    pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
92        Confirm::new()
93            .with_prompt(prompt)
94            .default(default)
95            .interact()
96            .map_err(|e| GitXError::GitCommand(format!("Confirmation cancelled: {e}")))
97    }
98
99    /// Find and rank items using fuzzy matching
100    pub fn fuzzy_find<T: AsRef<str>>(
101        items: &[T],
102        query: &str,
103        limit: Option<usize>,
104    ) -> Vec<(usize, i64)> {
105        let matcher = SkimMatcherV2::default();
106        let mut results: Vec<(usize, i64)> = items
107            .iter()
108            .enumerate()
109            .filter_map(|(idx, item)| {
110                matcher
111                    .fuzzy_match(item.as_ref(), query)
112                    .map(|score| (idx, score))
113            })
114            .collect();
115
116        // Sort by score (highest first)
117        results.sort_by(|a, b| b.1.cmp(&a.1));
118
119        if let Some(limit) = limit {
120            results.truncate(limit);
121        }
122
123        results
124    }
125
126    /// Select from a list with automatic fallback for non-interactive mode
127    pub fn select_or_first<T: AsRef<str> + Clone + ToString>(
128        items: &[T],
129        prompt: &str,
130    ) -> Result<T> {
131        if items.is_empty() {
132            return Err(GitXError::GitCommand("No items to select from".to_string()));
133        }
134
135        if !Self::is_interactive() {
136            // In non-interactive mode, just return the first item
137            return Ok(items[0].clone());
138        }
139
140        Self::fuzzy_select(items, prompt, Some(0))
141    }
142
143    /// Confirm or auto-accept in non-interactive mode
144    pub fn confirm_or_accept(prompt: &str, default: bool) -> Result<bool> {
145        if !Self::is_interactive() {
146            // In non-interactive mode, return the default
147            return Ok(default);
148        }
149
150        Self::confirm(prompt, default)
151    }
152}
153
154/// Builder for creating complex interactive workflows
155pub struct InteractiveBuilder {
156    steps: Vec<InteractiveStep>,
157}
158
159#[derive(Debug)]
160enum InteractiveStep {
161    Confirm {
162        prompt: String,
163        default: bool,
164    },
165    Select {
166        prompt: String,
167        items: Vec<String>,
168    },
169    Input {
170        prompt: String,
171        default: Option<String>,
172    },
173}
174
175impl InteractiveBuilder {
176    pub fn new() -> Self {
177        Self { steps: Vec::new() }
178    }
179
180    pub fn confirm(mut self, prompt: &str, default: bool) -> Self {
181        self.steps.push(InteractiveStep::Confirm {
182            prompt: prompt.to_string(),
183            default,
184        });
185        self
186    }
187
188    pub fn select(mut self, prompt: &str, items: Vec<String>) -> Self {
189        self.steps.push(InteractiveStep::Select {
190            prompt: prompt.to_string(),
191            items,
192        });
193        self
194    }
195
196    pub fn input(mut self, prompt: &str, default: Option<String>) -> Self {
197        self.steps.push(InteractiveStep::Input {
198            prompt: prompt.to_string(),
199            default,
200        });
201        self
202    }
203
204    pub fn execute(self) -> Result<InteractiveResults> {
205        let mut results = InteractiveResults::new();
206
207        for step in self.steps {
208            match step {
209                InteractiveStep::Confirm { prompt, default } => {
210                    let result = Interactive::confirm_or_accept(&prompt, default)?;
211                    results.confirmations.push(result);
212                }
213                InteractiveStep::Select { prompt, items } => {
214                    let result = Interactive::select_or_first(&items, &prompt)?;
215                    results.selections.push(result);
216                }
217                InteractiveStep::Input { prompt, default } => {
218                    let result = Interactive::text_input(&prompt, default.as_deref(), None)?;
219                    results.inputs.push(result);
220                }
221            }
222        }
223
224        Ok(results)
225    }
226}
227
228impl Default for InteractiveBuilder {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234#[derive(Debug)]
235pub struct InteractiveResults {
236    pub confirmations: Vec<bool>,
237    pub selections: Vec<String>,
238    pub inputs: Vec<String>,
239}
240
241impl InteractiveResults {
242    fn new() -> Self {
243        Self {
244            confirmations: Vec::new(),
245            selections: Vec::new(),
246            inputs: Vec::new(),
247        }
248    }
249
250    pub fn get_confirmation(&self, index: usize) -> Option<bool> {
251        self.confirmations.get(index).copied()
252    }
253
254    pub fn get_selection(&self, index: usize) -> Option<&str> {
255        self.selections.get(index).map(|s| s.as_str())
256    }
257
258    pub fn get_input(&self, index: usize) -> Option<&str> {
259        self.inputs.get(index).map(|s| s.as_str())
260    }
261}