use std::{
collections::HashSet,
io::{IsTerminal, Write},
path::Path,
};
use anyhow::{Result, bail};
use dialoguer::{Confirm, theme::ColorfulTheme};
use termcolor::{StandardStream, WriteColor};
use crate::core::{
ast::infrastructure::env::find_all_env_vars,
env::{EnvFileItem, find_workspace_root, get_modules_path, parse_env_items_from_str},
rendered_template::RenderedTemplatesCache,
string::closest_matches,
};
const MAX_SUGGESTIONS: usize = 3;
pub(crate) fn names_in_pulled_config(content: &str) -> HashSet<String> {
parse_env_items_from_str(content)
.into_iter()
.filter_map(|item| match item {
EnvFileItem::KeyValue(key, _) => Some(key),
EnvFileItem::SectionHeader(_) => None,
})
.collect()
}
pub(crate) fn names_declared_in_workspace(app_root: &Path) -> Option<HashSet<String>> {
let workspace_root = find_workspace_root(app_root).ok()?;
let modules_path = get_modules_path(&workspace_root).ok()?;
let rendered_templates_cache = RenderedTemplatesCache::new();
let project_env_vars = find_all_env_vars(&modules_path, &rendered_templates_cache).ok()?;
if project_env_vars.is_empty() {
return None;
}
Some(
project_env_vars
.values()
.flatten()
.map(|usage| usage.var_name.clone())
.collect(),
)
}
pub(crate) fn is_known_name(
key: &str,
in_config: &HashSet<String>,
declared: Option<&HashSet<String>>,
) -> bool {
if in_config.contains(key) {
return true;
}
match declared {
Some(declared) => declared.contains(key),
None => true,
}
}
pub(crate) fn suggestions_for(
key: &str,
in_config: &HashSet<String>,
declared: Option<&HashSet<String>>,
) -> Vec<String> {
let mut candidates: Vec<String> = in_config
.iter()
.chain(declared.into_iter().flatten())
.cloned()
.collect();
candidates.sort();
candidates.dedup();
closest_matches(key, &candidates, MAX_SUGGESTIONS)
}
pub(crate) fn format_suggestions(suggestions: &[String]) -> String {
let quoted: Vec<String> = suggestions.iter().map(|s| format!("'{}'", s)).collect();
match quoted.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} or {}", rest.join(", "), last),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn confirm_if_undeclared(
stdout: &mut StandardStream,
key: &str,
app_root: &Path,
pulled_config: &str,
environment: &str,
region: &str,
force: bool,
) -> Result<()> {
let in_config = names_in_pulled_config(pulled_config);
if in_config.contains(key) {
return Ok(());
}
let declared = names_declared_in_workspace(app_root);
if is_known_name(key, &in_config, declared.as_ref()) {
return Ok(());
}
log_warn!(
stdout,
"'{}' is not declared by any component in this application, and no variable by that name exists in {} ({}).",
key,
environment,
region
);
let suggestions = suggestions_for(key, &in_config, declared.as_ref());
if !suggestions.is_empty() {
log_warn!(
stdout,
" Did you mean {}?",
format_suggestions(&suggestions)
);
}
log_warn!(
stdout,
" Setting it will store the value under a name nothing reads."
);
if force {
log_warn!(stdout, " Continuing because --force was given.");
return Ok(());
}
if !std::io::stdin().is_terminal() {
log_warn!(
stdout,
" Continuing: not an interactive terminal, so there is nobody to ask. Pass --force to silence this."
);
return Ok(());
}
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Set '{}' anyway?", key))
.default(false)
.interact()?;
if !confirmed {
bail!("aborted — nothing was set");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn set_of(names: &[&str]) -> HashSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_names_in_pulled_config_collects_across_scopes() {
let content = "# application\n\
DB_HOST=db.example.com\n\
# payments (svc-123)\n\
STRIPE_API_KEY=sk_live_x\n\
# mailer-worker (wkr-456)\n\
QUEUE_NAME=mail\n";
let names = names_in_pulled_config(content);
assert_eq!(names.len(), 3);
assert!(names.contains("DB_HOST"));
assert!(names.contains("STRIPE_API_KEY"));
assert!(names.contains("QUEUE_NAME"));
}
#[test]
fn test_names_in_pulled_config_includes_valueless_required_vars() {
let content = "# application\nDB_HOST=db.example.com\nECS_AGENT_URI=\n";
let names = names_in_pulled_config(content);
assert!(names.contains("ECS_AGENT_URI"));
}
#[test]
fn test_names_in_pulled_config_ignores_headers() {
let names = names_in_pulled_config("# application\n# payments (svc-1)\n");
assert!(names.is_empty());
}
#[test]
fn test_is_known_name_from_platform_config() {
let in_config = set_of(&["DB_HOST"]);
let declared = set_of(&["REDIS_URL"]);
assert!(is_known_name("DB_HOST", &in_config, Some(&declared)));
}
#[test]
fn test_is_known_name_from_workspace_declaration() {
let in_config = set_of(&["DB_HOST"]);
let declared = set_of(&["REDIS_URL"]);
assert!(is_known_name("REDIS_URL", &in_config, Some(&declared)));
}
#[test]
fn test_unknown_name_is_flagged() {
let in_config = set_of(&["DB_HOST"]);
let declared = set_of(&["REDIS_URL"]);
assert!(!is_known_name(
"TWILIO_ACCOUNT_TOKEN",
&in_config,
Some(&declared)
));
}
#[test]
fn test_unscannable_workspace_never_flags() {
let in_config = set_of(&["DB_HOST"]);
assert!(is_known_name("ANYTHING_AT_ALL", &in_config, None));
}
#[test]
fn test_suggestions_draw_from_both_sources() {
let in_config = set_of(&["TWILIO_ACCOUNT_SID"]);
let declared = set_of(&["TWILIO_AUTH_TOKEN"]);
let suggestions = suggestions_for("TWILIO_ACCOUNT_TOKEN", &in_config, Some(&declared));
assert!(suggestions.contains(&"TWILIO_ACCOUNT_SID".to_string()));
assert!(suggestions.contains(&"TWILIO_AUTH_TOKEN".to_string()));
}
#[test]
fn test_suggestions_are_capped() {
let in_config = set_of(&["DB_HOSTA", "DB_HOSTB", "DB_HOSTC", "DB_HOSTD"]);
let suggestions = suggestions_for("DB_HOST", &in_config, None);
assert_eq!(suggestions.len(), MAX_SUGGESTIONS);
}
#[test]
fn test_format_suggestions_shapes() {
assert_eq!(format_suggestions(&[]), "");
assert_eq!(format_suggestions(&["A".to_string()]), "'A'");
assert_eq!(
format_suggestions(&["A".to_string(), "B".to_string()]),
"'A' or 'B'"
);
assert_eq!(
format_suggestions(&["A".to_string(), "B".to_string(), "C".to_string()]),
"'A', 'B' or 'C'"
);
}
}