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