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
22pub 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#[instrument(skip_all)]
46pub async fn resolve_completion(
47 completion: &VariableCompletion,
48 context: Option<Arc<BTreeMap<String, String>>>,
49) -> Result<Vec<String>, String> {
50 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
95fn resolve_suggestions_provider(suggestions_provider: &str, context: Option<&BTreeMap<String, String>>) -> String {
97 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 if let Some(context) = context
108 && required_vars
109 .iter()
110 .all(|(_, flat_name)| context.contains_key(flat_name))
111 {
112 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 String::new()
123 }
124 })
125 .to_string()
126}
127
128fn 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 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); 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); 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 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 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}