use std::{
collections::BTreeMap,
sync::{Arc, LazyLock},
time::Duration,
};
use futures_util::{
Stream,
stream::{self, StreamExt},
};
use regex::{Captures, Regex};
use tracing::instrument;
use crate::{
errors::UserFacingError,
model::VariableCompletion,
utils::{COMMAND_VARIABLE_REGEX, decode_output, flatten_variable_name, prepare_command_execution},
};
const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
pub async fn resolve_completions(
completions: Vec<VariableCompletion>,
context: BTreeMap<String, String>,
) -> impl Stream<Item = (f64, Result<Vec<String>, String>)> {
let context = Arc::new(context);
let num_completions = completions.len();
stream::iter(completions.into_iter().enumerate())
.map(move |(ix, completion)| {
let context = context.clone();
let score_boost = (num_completions - 1 - ix) as f64;
async move {
let result = resolve_completion(&completion, Some(context)).await;
(score_boost, result)
}
})
.buffer_unordered(4)
}
#[instrument(skip_all)]
pub async fn resolve_completion(
completion: &VariableCompletion,
context: Option<Arc<BTreeMap<String, String>>>,
) -> Result<Vec<String>, String> {
let command = resolve_suggestions_provider(&completion.suggestions_provider, context.as_deref());
if command.is_empty() {
return Err(UserFacingError::CompletionEmptySuggestionsProvider.to_string());
}
if completion.is_global() {
tracing::info!("Resolving completion for global {} variable", completion.flat_variable);
} else {
tracing::info!(
"Resolving completion for {} variable ({} command)",
completion.flat_variable,
completion.flat_root_cmd
);
}
let mut cmd = prepare_command_execution(&command, false, false).expect("infallible");
Ok(match tokio::time::timeout(COMMAND_TIMEOUT, cmd.output()).await {
Err(_) => {
tracing::warn!("Timeout executing dynamic completion command: '{command}'");
return Err(String::from("Timeout executing command provider"));
}
Ok(Ok(output)) if output.status.success() => {
let stdout = decode_output(&output.stdout);
tracing::trace!("Output:\n{stdout}");
let suggestions = stdout
.lines()
.map(String::from)
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>();
tracing::debug!("Resolved {} suggestions", suggestions.len());
suggestions
}
Ok(Ok(output)) => {
let stderr = decode_output(&output.stderr);
tracing::error!("Error executing dynamic completion command: '{command}':\n{stderr}");
return Err(stderr.into());
}
Ok(Err(err)) => {
tracing::error!("Failed to execute dynamic completion command: '{command}': {err}");
return Err(err.to_string());
}
})
}
fn resolve_suggestions_provider(suggestions_provider: &str, context: Option<&BTreeMap<String, String>>) -> String {
static OUTER_CONDITIONAL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{((?:[^{}]*\{\{[^}]*\}\})+[^{}]*)\}\}").unwrap());
OUTER_CONDITIONAL_REGEX
.replace_all(suggestions_provider, |caps: &Captures| {
let block_content = &caps[1];
let required_vars = find_variables_in_block(block_content);
if let Some(context) = context
&& required_vars
.iter()
.all(|(_, flat_name)| context.contains_key(flat_name))
{
let mut resolved_block = block_content.to_string();
for (variable, flat_name) in required_vars {
if let Some(value) = context.get(&flat_name) {
resolved_block = resolved_block.replace(&format!("{{{{{variable}}}}}"), value);
}
}
resolved_block
} else {
String::new()
}
})
.to_string()
}
fn find_variables_in_block(block_content: &str) -> Vec<(String, String)> {
COMMAND_VARIABLE_REGEX
.captures_iter(block_content)
.map(|cap| (cap[1].to_string(), flatten_variable_name(&cap[1])))
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashSet};
use futures_util::StreamExt;
use pretty_assertions::assert_eq;
use super::*;
#[tokio::test]
async fn test_resolve_completions_empty() {
let stream = resolve_completions(Vec::new(), BTreeMap::new()).await;
let (suggestions, errors) = run_and_collect(stream).await;
assert!(suggestions.is_empty());
assert!(errors.is_empty());
}
#[tokio::test]
async fn test_resolve_completions_with_empty_command() {
let completions = vec![VariableCompletion::new("user", "test", "VAR", "")];
let stream = resolve_completions(completions, BTreeMap::new()).await;
let (suggestions, errors) = run_and_collect(stream).await;
assert!(suggestions.is_empty());
assert_eq!(errors.len(), 1, "Expected an error for an empty provider");
}
#[tokio::test]
async fn test_resolve_completions_with_invalid_command() {
let completions = vec![VariableCompletion::new("user", "test", "VAR", "nonexistent_command")];
let stream = resolve_completions(completions, BTreeMap::new()).await;
let (suggestions, errors) = run_and_collect(stream).await;
assert!(suggestions.is_empty());
assert_eq!(errors.len(), 1, "Expected an error for a nonexistent command");
}
#[tokio::test]
async fn test_resolve_completions_returns_all_results_including_duplicates() {
let completions = vec![
VariableCompletion::new("user", "test", "VAR", "printf 'foo\nbar'"),
VariableCompletion::new("user", "test", "VAR2", "printf 'baz\nfoo'"),
];
let stream = resolve_completions(completions, BTreeMap::new()).await;
let (suggestions, errors) = run_and_collect(stream).await;
assert!(errors.is_empty());
assert_eq!(suggestions.len(), 2);
let mut suggestions = suggestions;
suggestions.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
assert_eq!(suggestions[0].0, 1.0); assert_eq!(
HashSet::<String>::from_iter(suggestions[0].1.iter().cloned()),
HashSet::from_iter(vec!["foo".to_string(), "bar".to_string()])
);
assert_eq!(suggestions[1].0, 0.0); assert_eq!(
HashSet::<String>::from_iter(suggestions[1].1.iter().cloned()),
HashSet::from_iter(vec!["baz".to_string(), "foo".to_string()])
);
}
#[tokio::test]
async fn test_resolve_completions_with_mixed_success_and_failure() {
let completions = vec![
VariableCompletion::new("user", "test", "VAR1", "printf 'success1'"),
VariableCompletion::new("user", "test", "VAR2", "this_is_not_a_command"),
VariableCompletion::new("user", "test", "VAR3", "printf 'success2'"),
];
let stream = resolve_completions(completions, BTreeMap::new()).await;
let (suggestions, errors) = run_and_collect(stream).await;
assert_eq!(suggestions.len(), 2);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("this_is_not_a_command"));
}
#[tokio::test]
async fn test_resolve_completions_with_multiple_errors() {
let completions = vec![
VariableCompletion::new("user", "test", "VAR1", "cmd1_invalid"),
VariableCompletion::new("user", "test", "VAR2", "cmd2_also_invalid"),
];
let stream = resolve_completions(completions, BTreeMap::new()).await;
let (suggestions, errors) = run_and_collect(stream).await;
assert!(suggestions.is_empty());
assert_eq!(errors.len(), 2);
assert!(errors.iter().any(|e| e.contains("cmd1_invalid")));
assert!(errors.iter().any(|e| e.contains("cmd2_also_invalid")));
}
#[test]
fn test_no_conditional_blocks() {
let command = "kubectl get pods";
let context = context_from(&[("context", "my-cluster")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "kubectl get pods");
}
#[test]
fn test_single_conditional_variable_present() {
let command = "echo Hello {{{{name}}}}";
let context = context_from(&[("name", "World")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "echo Hello World");
}
#[test]
fn test_single_conditional_variable_absent() {
let command = "echo Hello {{{{name}}}}";
let context = BTreeMap::new();
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "echo Hello ");
}
#[test]
fn test_single_conditional_block_present() {
let command = "kubectl get pods {{--context {{context}}}}";
let context = context_from(&[("context", "my-cluster")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "kubectl get pods --context my-cluster");
}
#[test]
fn test_single_conditional_block_absent() {
let command = "kubectl get pods {{--context {{context}}}}";
let result = resolve_suggestions_provider(command, None);
assert_eq!(result, "kubectl get pods ");
}
#[test]
fn test_multiple_conditional_blocks_all_present() {
let command = "kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}";
let context = context_from(&[("context", "my-cluster"), ("namespace", "prod")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "kubectl get pods --context my-cluster -n prod");
}
#[test]
fn test_multiple_conditional_blocks_some_present() {
let command = "kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}";
let context = context_from(&[("namespace", "prod")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "kubectl get pods -n prod");
}
#[test]
fn test_multiple_conditional_blocks_none_present() {
let command = "kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}";
let context = BTreeMap::new();
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "kubectl get pods ");
}
#[test]
fn test_block_with_multiple_inner_variables_all_present() {
let command = "command {{--user {{user}} --password {{password}}}}";
let context = context_from(&[("user", "admin"), ("password", "secret")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "command --user admin --password secret");
}
#[test]
fn test_block_with_multiple_inner_variables_some_present() {
let command = "command {{--user {{user}} --password {{password}}}}";
let context = context_from(&[("user", "admin")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "command ");
}
#[test]
fn test_mixed_static_and_conditional_parts() {
let command = "docker run {{--name {{container_name}}}} -p 8080:80 {{image_name}}";
let context = context_from(&[("container_name", "my-app")]);
let result = resolve_suggestions_provider(command, Some(&context));
assert_eq!(result, "docker run --name my-app -p 8080:80 {{image_name}}");
}
fn context_from(data: &[(&str, &str)]) -> BTreeMap<String, String> {
data.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
async fn run_and_collect(
stream: impl Stream<Item = (f64, Result<Vec<String>, String>)>,
) -> (Vec<(f64, Vec<String>)>, Vec<String>) {
let results = stream.collect::<Vec<_>>().await;
let mut suggestions = Vec::new();
let mut errors = Vec::new();
for (score, result) in results {
match result {
Ok(s) => suggestions.push((score, s)),
Err(e) => errors.push(e),
}
}
(suggestions, errors)
}
}