intelli_shell/utils/
completion.rs

1use std::{
2    collections::BTreeMap,
3    sync::{Arc, LazyLock},
4    time::Duration,
5};
6
7use futures_util::{
8    Stream,
9    stream::{self, StreamExt},
10};
11use regex::{Captures, Regex};
12use tracing::instrument;
13
14use crate::{
15    errors::UserFacingError,
16    model::VariableCompletion,
17    utils::{COMMAND_VARIABLE_REGEX, decode_output, flatten_variable_name, prepare_command_execution},
18};
19
20const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
21
22/// Fetches suggestions from variable completions by executing their commands
23///
24/// Returns a stream where each item is a tuple of (score_boost, result)
25pub async fn resolve_completions(
26    completions: Vec<VariableCompletion>,
27    context: BTreeMap<String, String>,
28) -> impl Stream<Item = (f64, Result<Vec<String>, String>)> {
29    let context = Arc::new(context);
30    let num_completions = completions.len();
31
32    stream::iter(completions.into_iter().enumerate())
33        .map(move |(ix, completion)| {
34            let context = context.clone();
35            let score_boost = (num_completions - 1 - ix) as f64;
36            async move {
37                let result = resolve_completion(&completion, Some(context)).await;
38                (score_boost, result)
39            }
40        })
41        .buffer_unordered(4)
42}
43
44/// Fetches suggestions from a variable completion by executing its provider command
45#[instrument(skip_all)]
46pub async fn resolve_completion(
47    completion: &VariableCompletion,
48    context: Option<Arc<BTreeMap<String, String>>>,
49) -> Result<Vec<String>, String> {
50    // Resolve the command based on the context
51    let command = resolve_suggestions_provider(&completion.suggestions_provider, context.as_deref());
52    if command.is_empty() {
53        return Err(UserFacingError::CompletionEmptySuggestionsProvider.to_string());
54    }
55
56    if completion.is_global() {
57        tracing::info!("Resolving completion for global {} variable", completion.flat_variable);
58    } else {
59        tracing::info!(
60            "Resolving completion for {} variable ({} command)",
61            completion.flat_variable,
62            completion.flat_root_cmd
63        );
64    }
65
66    let mut cmd = prepare_command_execution(&command, false, false).expect("infallible");
67    Ok(match tokio::time::timeout(COMMAND_TIMEOUT, cmd.output()).await {
68        Err(_) => {
69            tracing::warn!("Timeout executing dynamic completion command: '{command}'");
70            return Err(String::from("Timeout executing command provider"));
71        }
72        Ok(Ok(output)) if output.status.success() => {
73            let stdout = decode_output(&output.stdout);
74            tracing::trace!("Output:\n{stdout}");
75            let suggestions = stdout
76                .lines()
77                .map(String::from)
78                .filter(|s| !s.trim().is_empty())
79                .collect::<Vec<_>>();
80            tracing::debug!("Resolved {} suggestions", suggestions.len());
81            suggestions
82        }
83        Ok(Ok(output)) => {
84            let stderr = decode_output(&output.stderr);
85            tracing::error!("Error executing dynamic completion command: '{command}':\n{stderr}");
86            return Err(stderr.into());
87        }
88        Ok(Err(err)) => {
89            tracing::error!("Failed to execute dynamic completion command: '{command}': {err}");
90            return Err(err.to_string());
91        }
92    })
93}
94
95/// Resolves a command with implicit conditional blocks
96fn resolve_suggestions_provider(suggestions_provider: &str, context: Option<&BTreeMap<String, String>>) -> String {
97    /// Regex to find the outer conditional blocks
98    static OUTER_CONDITIONAL_REGEX: LazyLock<Regex> =
99        LazyLock::new(|| Regex::new(r"\{\{((?:[^{}]*\{\{[^}]*\}\})+[^{}]*)\}\}").unwrap());
100
101    OUTER_CONDITIONAL_REGEX
102        .replace_all(suggestions_provider, |caps: &Captures| {
103            let block_content = &caps[1];
104            let required_vars = find_variables_in_block(block_content);
105
106            // Check if all variables required by this block are in the context
107            if let Some(context) = context
108                && required_vars
109                    .iter()
110                    .all(|(_, flat_name)| context.contains_key(flat_name))
111            {
112                // If so, replace the variables within the block and return it
113                let mut resolved_block = block_content.to_string();
114                for (variable, flat_name) in required_vars {
115                    if let Some(value) = context.get(&flat_name) {
116                        resolved_block = resolved_block.replace(&format!("{{{{{variable}}}}}"), value);
117                    }
118                }
119                resolved_block
120            } else {
121                // Otherwise, this block is omitted
122                String::new()
123            }
124        })
125        .to_string()
126}
127
128/// Extracts all variable names from a string segment, returning both the variable and the flattened variable name
129fn find_variables_in_block(block_content: &str) -> Vec<(String, String)> {
130    COMMAND_VARIABLE_REGEX
131        .captures_iter(block_content)
132        .map(|cap| (cap[1].to_string(), flatten_variable_name(&cap[1])))
133        .collect()
134}
135
136#[cfg(test)]
137mod tests {
138    use std::collections::{BTreeMap, HashSet};
139
140    use futures_util::StreamExt;
141    use pretty_assertions::assert_eq;
142
143    use super::*;
144
145    #[tokio::test]
146    async fn test_resolve_completions_empty() {
147        let stream = resolve_completions(Vec::new(), BTreeMap::new()).await;
148        let (suggestions, errors) = run_and_collect(stream).await;
149        assert!(suggestions.is_empty());
150        assert!(errors.is_empty());
151    }
152
153    #[tokio::test]
154    async fn test_resolve_completions_with_empty_command() {
155        let completions = vec![VariableCompletion::new("user", "test", "VAR", "")];
156        let stream = resolve_completions(completions, BTreeMap::new()).await;
157        let (suggestions, errors) = run_and_collect(stream).await;
158        assert!(suggestions.is_empty());
159        assert_eq!(errors.len(), 1, "Expected an error for an empty provider");
160    }
161
162    #[tokio::test]
163    async fn test_resolve_completions_with_invalid_command() {
164        let completions = vec![VariableCompletion::new("user", "test", "VAR", "nonexistent_command")];
165        let stream = resolve_completions(completions, BTreeMap::new()).await;
166        let (suggestions, errors) = run_and_collect(stream).await;
167        assert!(suggestions.is_empty());
168        assert_eq!(errors.len(), 1, "Expected an error for a nonexistent command");
169    }
170
171    #[tokio::test]
172    async fn test_resolve_completions_returns_all_results_including_duplicates() {
173        let completions = vec![
174            VariableCompletion::new("user", "test", "VAR", "printf 'foo\nbar'"),
175            VariableCompletion::new("user", "test", "VAR2", "printf 'baz\nfoo'"),
176        ];
177        let stream = resolve_completions(completions, BTreeMap::new()).await;
178        let (suggestions, errors) = run_and_collect(stream).await;
179
180        assert!(errors.is_empty());
181        assert_eq!(suggestions.len(), 2);
182
183        // Sort by score to have a deterministic order for assertion
184        let mut suggestions = suggestions;
185        suggestions.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
186
187        assert_eq!(suggestions[0].0, 1.0); // First completion gets higher score boost
188        assert_eq!(
189            HashSet::<String>::from_iter(suggestions[0].1.iter().cloned()),
190            HashSet::from_iter(vec!["foo".to_string(), "bar".to_string()])
191        );
192
193        assert_eq!(suggestions[1].0, 0.0); // Second completion gets lower score boost
194        assert_eq!(
195            HashSet::<String>::from_iter(suggestions[1].1.iter().cloned()),
196            HashSet::from_iter(vec!["baz".to_string(), "foo".to_string()])
197        );
198    }
199
200    #[tokio::test]
201    async fn test_resolve_completions_with_mixed_success_and_failure() {
202        let completions = vec![
203            VariableCompletion::new("user", "test", "VAR1", "printf 'success1'"),
204            VariableCompletion::new("user", "test", "VAR2", "this_is_not_a_command"),
205            VariableCompletion::new("user", "test", "VAR3", "printf 'success2'"),
206        ];
207        let stream = resolve_completions(completions, BTreeMap::new()).await;
208        let (suggestions, errors) = run_and_collect(stream).await;
209
210        assert_eq!(suggestions.len(), 2);
211        assert_eq!(errors.len(), 1);
212        assert!(errors[0].contains("this_is_not_a_command"));
213    }
214
215    #[tokio::test]
216    async fn test_resolve_completions_with_multiple_errors() {
217        let completions = vec![
218            VariableCompletion::new("user", "test", "VAR1", "cmd1_invalid"),
219            VariableCompletion::new("user", "test", "VAR2", "cmd2_also_invalid"),
220        ];
221        let stream = resolve_completions(completions, BTreeMap::new()).await;
222        let (suggestions, errors) = run_and_collect(stream).await;
223
224        assert!(suggestions.is_empty());
225        assert_eq!(errors.len(), 2);
226        assert!(errors.iter().any(|e| e.contains("cmd1_invalid")));
227        assert!(errors.iter().any(|e| e.contains("cmd2_also_invalid")));
228    }
229
230    #[test]
231    fn test_no_conditional_blocks() {
232        let command = "kubectl get pods";
233        let context = context_from(&[("context", "my-cluster")]);
234        let result = resolve_suggestions_provider(command, Some(&context));
235        assert_eq!(result, "kubectl get pods");
236    }
237
238    #[test]
239    fn test_single_conditional_variable_present() {
240        let command = "echo Hello {{{{name}}}}";
241        let context = context_from(&[("name", "World")]);
242        let result = resolve_suggestions_provider(command, Some(&context));
243        assert_eq!(result, "echo Hello World");
244    }
245
246    #[test]
247    fn test_single_conditional_variable_absent() {
248        let command = "echo Hello {{{{name}}}}";
249        let context = BTreeMap::new();
250        let result = resolve_suggestions_provider(command, Some(&context));
251        assert_eq!(result, "echo Hello ");
252    }
253
254    #[test]
255    fn test_single_conditional_block_present() {
256        let command = "kubectl get pods {{--context {{context}}}}";
257        let context = context_from(&[("context", "my-cluster")]);
258        let result = resolve_suggestions_provider(command, Some(&context));
259        assert_eq!(result, "kubectl get pods --context my-cluster");
260    }
261
262    #[test]
263    fn test_single_conditional_block_absent() {
264        let command = "kubectl get pods {{--context {{context}}}}";
265        let result = resolve_suggestions_provider(command, None);
266        assert_eq!(result, "kubectl get pods ");
267    }
268
269    #[test]
270    fn test_multiple_conditional_blocks_all_present() {
271        let command = "kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}";
272        let context = context_from(&[("context", "my-cluster"), ("namespace", "prod")]);
273        let result = resolve_suggestions_provider(command, Some(&context));
274        assert_eq!(result, "kubectl get pods --context my-cluster -n prod");
275    }
276
277    #[test]
278    fn test_multiple_conditional_blocks_some_present() {
279        let command = "kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}";
280        let context = context_from(&[("namespace", "prod")]);
281        let result = resolve_suggestions_provider(command, Some(&context));
282        assert_eq!(result, "kubectl get pods  -n prod");
283    }
284
285    #[test]
286    fn test_multiple_conditional_blocks_none_present() {
287        let command = "kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}";
288        let context = BTreeMap::new();
289        let result = resolve_suggestions_provider(command, Some(&context));
290        assert_eq!(result, "kubectl get pods  ");
291    }
292
293    #[test]
294    fn test_block_with_multiple_inner_variables_all_present() {
295        let command = "command {{--user {{user}} --password {{password}}}}";
296        let context = context_from(&[("user", "admin"), ("password", "secret")]);
297        let result = resolve_suggestions_provider(command, Some(&context));
298        assert_eq!(result, "command --user admin --password secret");
299    }
300
301    #[test]
302    fn test_block_with_multiple_inner_variables_some_present() {
303        let command = "command {{--user {{user}} --password {{password}}}}";
304        let context = context_from(&[("user", "admin")]);
305        let result = resolve_suggestions_provider(command, Some(&context));
306        assert_eq!(result, "command ");
307    }
308
309    #[test]
310    fn test_mixed_static_and_conditional_parts() {
311        let command = "docker run {{--name {{container_name}}}} -p 8080:80 {{image_name}}";
312        let context = context_from(&[("container_name", "my-app")]);
313        let result = resolve_suggestions_provider(command, Some(&context));
314        assert_eq!(result, "docker run --name my-app -p 8080:80 {{image_name}}");
315    }
316
317    /// Helper to create a BTreeMap from a slice of tuples
318    fn context_from(data: &[(&str, &str)]) -> BTreeMap<String, String> {
319        data.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
320    }
321
322    /// Helper to collect results from the stream for testing purposes
323    async fn run_and_collect(
324        stream: impl Stream<Item = (f64, Result<Vec<String>, String>)>,
325    ) -> (Vec<(f64, Vec<String>)>, Vec<String>) {
326        let results = stream.collect::<Vec<_>>().await;
327        let mut suggestions = Vec::new();
328        let mut errors = Vec::new();
329
330        for (score, result) in results {
331            match result {
332                Ok(s) => suggestions.push((score, s)),
333                Err(e) => errors.push(e),
334            }
335        }
336        (suggestions, errors)
337    }
338}