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
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, 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 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
84fn resolve_suggestions_provider(suggestions_provider: &str, context: Option<&BTreeMap<String, String>>) -> String {
86 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 if let Some(context) = context
97 && required_vars
98 .iter()
99 .all(|(_, flat_name)| context.contains_key(flat_name))
100 {
101 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 String::new()
112 }
113 })
114 .to_string()
115}
116
117fn 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 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); 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); 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 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 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}