// src/main.rs
use anyhow::{Context, Result, bail};
use clap::{ArgGroup, Parser};
use regex::Regex;
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use std::collections::{BTreeSet, HashMap};
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
const BASH_COMPLETION: &str = include_str!("../completions/tplenv.bash");
const ZSH_COMPLETION: &str = include_str!("../completions/_tplenv");
/// Substitute env placeholders using environment variables (`{{VARNAME}}`, `$VARNAME`, `${VARNAME}`),
/// and {{ .Values.key }} placeholders using a YAML values file (default: Values.yaml).
///
/// Examples:
/// {{NAMESPACE}} -> env var NAMESPACE
/// {{ .Values.namespace }} -> Values.yaml: namespace
/// {{ .Values.foo.bar }} -> Values.yaml: foo: { bar: ... }
#[derive(Parser, Debug)]
#[command(
name = "tplenv",
version,
about = "Fill placeholders in YAML templates using env vars and/or a values file",
long_about = "tplenv reads one or more template files and replaces placeholders:\n- {{VARNAME}}, $VARNAME, ${VARNAME} from environment variables\n- {{ .Values.key }} from a YAML values file\n\nYou can also run in values-only mode so env placeholders are read from environment.VARNAME in the values file.\n\nFile patterns:\n- --file-pattern matches files in one directory using * and <NUM>\n- matched files are processed in sorted filename order\n- output is one YAML multi-document stream (documents separated by ---)\n\nEval mode:\n- --eval prints prompted values as bash export statements\n- designed for: eval \"$(tplenv ... --create-values-file --eval)\"",
after_help = "Quick examples:\n tplenv --file app.yaml --values Values.yaml\n tplenv --file app.yaml --indent\n tplenv --file app.yaml --create-values-file\n tplenv --file app.yaml --value-file-only --create-values-file --force\n tplenv --file-pattern \"configs/<NUM>-*.yaml\" --values Values.yaml\n tplenv --file-pattern \"configs/<NUM>-*.yaml\" --output rendered.yaml\n eval \"$(tplenv --file app.yaml --create-values-file --eval)\"\n tplenv --install-completion\n tplenv --install-completion zsh\n",
disable_help_flag = false,
next_line_help = true,
group(
ArgGroup::new("input")
.args(["file", "file_pattern"])
)
)]
struct Args {
/// Single template file to render
#[arg(short = 'f', long = "file")]
file: Option<PathBuf>,
/// Render all files matching this pattern (supports * and <NUM>)
/// Output becomes one YAML multi-document stream.
#[arg(long = "file-pattern")]
file_pattern: Option<String>,
/// Values YAML file used for {{ .Values.* }} lookups and environment.* in --value-file-only mode
#[arg(
long = "values-file",
visible_alias = "values",
default_value = "Values.yaml"
)]
values: PathBuf,
/// Output file path (default: stdout). Use "-" to force stdout.
/// With multiple input files, output becomes one YAML multi-document stream.
#[arg(short = 'o', long = "output")]
output: Option<PathBuf>,
/// Show each placeholder replacement while rendering
#[arg(short = 'v', long = "verbose", default_value_t = false)]
verbose: bool,
/// Ask questions for missing placeholders, then write/update the values file first
/// Env placeholders are stored under environment.<VAR>.
/// environment.<VAR> in values file has priority over OS env vars.
#[arg(long = "create-values-file", default_value_t = false)]
create_values_file: bool,
/// With --create-values-file: ask for all keys, even if already set
#[arg(long = "force", default_value_t = false)]
force: bool,
/// Do not read OS environment variables; use values file key environment.<VAR> for env placeholders
#[arg(long = "value-file-only", default_value_t = false)]
value_file_only: bool,
/// Print prompted values as bash export statements (for use with eval "$( ... )")
#[arg(long = "eval", default_value_t = false)]
eval: bool,
/// Preserve indentation for multiline replacement values
#[arg(long = "indent", default_value_t = false)]
indent: bool,
/// Show template context before each --create-values-file prompt
#[arg(long = "context", default_value_t = false)]
context: bool,
/// Install shell completion (auto, bash, or zsh)
#[arg(
long = "install-completion",
num_args = 0..=1,
default_missing_value = "auto",
value_name = "SHELL"
)]
install_completion: Option<String>,
}
fn main() {
if let Err(e) = run() {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
if let Some(shell_arg) = args.install_completion.as_deref() {
install_completion(shell_arg)?;
return Ok(());
}
let input_files = discover_input_files(args.file.as_ref(), args.file_pattern.as_deref())?;
if input_files.len() > 1 {
ensure_all_yaml_files(&input_files)?;
}
let mut templates: Vec<(PathBuf, String)> = Vec::new();
for file in &input_files {
let input = fs::read_to_string(file)
.with_context(|| format!("failed to read file: {}", file.display()))?;
templates.push((file.clone(), input));
}
// One regex to match all supported placeholders:
// {{ .Values.namespace }} -> capture group 1 (path)
// {{NAMESPACE}} -> capture group 2
// ${NAMESPACE} -> capture group 3
// $NAMESPACE -> capture group 4
//
// Values paths are dot-separated identifiers: foo.bar.baz
let re = placeholder_regex()?;
let (env_vars, values_paths) = collect_placeholders_all(&templates, &re);
let prompt_contexts = collect_prompt_contexts(&templates, &re, args.context);
let prompt_order = collect_prompt_order(&templates, &re);
if args.force && !args.create_values_file {
bail!("--force can only be used together with --create-values-file");
}
if args.eval && !args.create_values_file {
bail!("--eval can only be used together with --create-values-file");
}
let include_environment_vars_in_prompts = args.create_values_file;
let existing_os_env_vars: BTreeSet<String> = if args.value_file_only {
BTreeSet::new()
} else {
env_vars
.iter()
.filter(|v| env::var_os(v).is_some())
.cloned()
.collect()
};
let existing_os_env_values: HashMap<String, String> = if args.value_file_only {
HashMap::new()
} else {
env_vars
.iter()
.filter_map(|v| env::var_os(v).map(|os| (v.clone(), os.to_string_lossy().to_string())))
.collect()
};
let needs_values_prompt =
!values_paths.is_empty() || (include_environment_vars_in_prompts && !env_vars.is_empty());
let mut prompted_values: Vec<(String, String)> = Vec::new();
if args.create_values_file && needs_values_prompt {
let prompt_opts = PromptUpdateOptions {
include_environment_vars: include_environment_vars_in_prompts,
skip_existing_env_vars: &existing_os_env_vars,
existing_os_env_values: &existing_os_env_values,
prompt_contexts: &prompt_contexts,
prompt_order: &prompt_order,
force: args.force,
verbose: args.verbose,
};
prompted_values =
prompt_and_update_values_file(&args.values, &values_paths, &env_vars, &prompt_opts)?;
}
let prompted_env_map = prompted_environment_values(&prompted_values);
// Load values YAML:
// - required when .Values placeholders exist
// - optional (if exists) for env placeholder precedence via environment.<VAR>
let values_yaml: Option<YamlValue> = if !values_paths.is_empty() {
load_values_yaml(&args.values)?
} else if !env_vars.is_empty() {
Some(load_values_yaml_if_exists(&args.values)?)
} else {
None
};
// Resolve placeholders
let mut missing_values: Vec<String> = Vec::new();
let mut missing_env: Vec<String> = Vec::new();
let mut env_map: HashMap<String, String> = HashMap::new();
if args.value_file_only {
if !env_vars.is_empty() {
let yaml = values_yaml
.as_ref()
.expect("values_yaml must be loaded in --value-file-only mode");
let (resolved, missing_paths) = resolve_env_from_values_file(&env_vars, yaml)?;
if args.verbose {
for (name, val) in &resolved {
if let Some(os) = env::var_os(name) {
let env_val = os.to_string_lossy().to_string();
if env_val != *val {
eprintln!(
"warning: env {name} differs from values file environment.{name}; using values file value"
);
}
}
}
}
env_map = resolved;
// Treat missing env substitutions as missing values file keys.
missing_env.clear();
for p in missing_paths {
missing_values.push(p);
}
}
} else {
for v in &env_vars {
let os_val = env::var_os(v).map(|os| os.to_string_lossy().to_string());
if let Some(yaml) = values_yaml.as_ref() {
let path = env_var_values_path(v);
if let Some(val) = lookup_yaml_path(yaml, &path) {
let values_val = yaml_value_to_string(val)?;
if args.verbose
&& let Some(env_val) = os_val.as_ref()
&& env_val != &values_val
{
eprintln!(
"warning: env {v} differs from values file {path}; using values file value"
);
}
env_map.insert(v.clone(), values_val);
continue;
}
}
if let Some(val) = prompted_env_map.get(v) {
env_map.insert(v.clone(), val.clone());
continue;
}
if let Some(val) = os_val {
env_map.insert(v.clone(), val);
} else {
missing_env.push(v.clone());
}
}
}
// Resolve values paths
let mut values_map: HashMap<String, String> = HashMap::new();
for p in &values_paths {
let yaml = values_yaml
.as_ref()
.expect("values_yaml must be loaded if values_paths is non-empty");
match lookup_yaml_path(yaml, p) {
Some(v) => {
let s = yaml_value_to_string(v)?;
values_map.insert(p.clone(), s);
}
None => missing_values.push(p.clone()),
}
}
// If anything missing, print all missing and fail
if !missing_env.is_empty() || !missing_values.is_empty() {
if !missing_env.is_empty() {
eprintln!("Missing/undefined environment variables:");
for v in &missing_env {
eprintln!("- {v}");
}
}
if !missing_values.is_empty() {
eprintln!("Missing keys in values file ({}):", args.values.display());
for p in &missing_values {
if p.starts_with("environment.") {
eprintln!("- {p}");
} else {
eprintln!("- .Values.{p}");
}
}
}
bail!("not all placeholders could be resolved");
}
// Render with logging (if verbose)
let mut rendered_outputs: Vec<(PathBuf, String)> = Vec::new();
for (path, input) in &templates {
let rendered = re.replace_all(input, |caps: ®ex::Captures| {
let raw = if let Some(p) = caps.get(1) {
let key = p.as_str();
let val = values_map.get(key).cloned().unwrap_or_default();
if args.verbose {
eprintln!("set .Values.{key} = {val}");
}
val
} else {
let key = extract_env_key(caps).unwrap_or("");
let val = env_map.get(key).cloned().unwrap_or_default();
if args.verbose {
if args.value_file_only {
eprintln!("set environment.{key} = {val}");
} else {
eprintln!("set env {key} = {val}");
}
}
val
};
if args.indent {
if let Some(m) = caps.get(0) {
format_replacement_with_indent(&raw, input, m.start(), m.end())
} else {
raw
}
} else {
raw
}
});
rendered_outputs.push((path.clone(), rendered.to_string()));
}
if args.eval {
// In eval mode, stdout should stay parseable as shell exports.
if args.output.is_some()
&& args
.output
.as_ref()
.map(|p| p.to_string_lossy() == "-")
.unwrap_or(false)
{
bail!("with --eval, --output - is not supported");
}
if args.output.is_some() {
write_outputs(args.output.as_ref(), &rendered_outputs)?;
}
let script = render_eval_exports_with_env(&prompted_values, &env_map);
let mut out = io::stdout().lock();
out.write_all(script.as_bytes())?;
} else {
write_outputs(args.output.as_ref(), &rendered_outputs)?;
}
Ok(())
}
fn placeholder_regex() -> Result<Regex> {
Ok(Regex::new(
r"\{\{\s*(?:\.Values\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)|([A-Za-z_][A-Za-z0-9_]*))\s*\}\}|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)",
)?)
}
fn collect_placeholders(input: &str, re: &Regex) -> (BTreeSet<String>, BTreeSet<String>) {
let mut env_vars: BTreeSet<String> = BTreeSet::new();
let mut values_paths: BTreeSet<String> = BTreeSet::new();
for cap in re.captures_iter(input) {
if let Some(p) = cap.get(1) {
values_paths.insert(p.as_str().to_string());
} else if let Some(v) = extract_env_key(&cap) {
env_vars.insert(v.to_string());
}
}
(env_vars, values_paths)
}
fn extract_env_key<'a>(caps: &'a regex::Captures<'a>) -> Option<&'a str> {
caps.get(2)
.or_else(|| caps.get(3))
.or_else(|| caps.get(4))
.map(|m| m.as_str())
}
fn collect_placeholders_all(
templates: &[(PathBuf, String)],
re: &Regex,
) -> (BTreeSet<String>, BTreeSet<String>) {
let mut env_vars: BTreeSet<String> = BTreeSet::new();
let mut values_paths: BTreeSet<String> = BTreeSet::new();
for (_, input) in templates {
let (env, values) = collect_placeholders(input, re);
env_vars.extend(env);
values_paths.extend(values);
}
(env_vars, values_paths)
}
fn discover_input_files(
file: Option<&PathBuf>,
file_pattern: Option<&str>,
) -> Result<Vec<PathBuf>> {
match (file, file_pattern) {
(Some(path), None) => Ok(vec![path.clone()]),
(None, Some(pattern)) => find_files_by_pattern(pattern),
(Some(_), Some(_)) => bail!("use only one of --file or --file-pattern"),
(None, None) => bail!("one of --file or --file-pattern is required"),
}
}
fn find_files_by_pattern(pattern: &str) -> Result<Vec<PathBuf>> {
let pattern_path = Path::new(pattern);
let dir = match pattern_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
};
let filename_pattern = pattern_path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("invalid --file-pattern: {pattern}"))?;
let re = file_pattern_regex(filename_pattern)?;
let mut files = Vec::new();
for entry in
fs::read_dir(&dir).with_context(|| format!("failed to read dir: {}", dir.display()))?
{
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_file() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if re.is_match(&name) {
files.push(dir.join(name.as_ref()));
}
}
files.sort();
if files.is_empty() {
bail!("no files matched --file-pattern {pattern}");
}
Ok(files)
}
fn file_pattern_regex(pattern: &str) -> Result<Regex> {
let escaped = regex::escape(pattern);
let with_num = escaped.replace("<NUM>", "[0-9]+");
let final_pattern = with_num.replace(r"\*", ".*");
Ok(Regex::new(&format!("^{final_pattern}$"))?)
}
fn load_values_yaml(path: &Path) -> Result<Option<YamlValue>> {
// If values placeholders are present, we require the file to exist & parse.
let text = fs::read_to_string(path)
.with_context(|| format!("failed to read values file: {}", path.display()))?;
let yaml: YamlValue = serde_yaml::from_str(&text)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(yaml))
}
fn load_values_yaml_if_exists(path: &Path) -> Result<YamlValue> {
if !path.exists() {
return Ok(YamlValue::Mapping(YamlMapping::new()));
}
let text = fs::read_to_string(path)
.with_context(|| format!("failed to read values file: {}", path.display()))?;
let yaml: YamlValue = serde_yaml::from_str(&text)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(yaml)
}
fn prompt_and_update_values_file(
path: &Path,
values_paths: &BTreeSet<String>,
env_vars: &BTreeSet<String>,
opts: &PromptUpdateOptions<'_>,
) -> Result<Vec<(String, String)>> {
let mut root = load_values_yaml_if_exists(path)?;
let mut prompted_values: Vec<(String, String)> = Vec::new();
let mut changed = false;
if opts.include_environment_vars && !opts.force {
for var in env_vars {
let path_key = env_var_values_path(var);
if lookup_yaml_path(&root, &path_key).is_none()
&& let Some(val) = opts.existing_os_env_values.get(var)
{
set_yaml_path(&mut root, &path_key, YamlValue::String(val.clone()));
prompted_values.push((path_key, val.clone()));
changed = true;
}
}
}
let env_skip = if opts.force {
BTreeSet::new()
} else {
opts.skip_existing_env_vars.clone()
};
let all_prompt_paths = collect_prompt_paths(
values_paths,
env_vars,
opts.include_environment_vars,
&env_skip,
);
let mut prompt_paths: Vec<String> = if opts.force {
all_prompt_paths.iter().cloned().collect()
} else {
all_prompt_paths
.iter()
.filter(|p| lookup_yaml_path(&root, p).is_none())
.cloned()
.collect()
};
let rank: HashMap<&str, usize> = opts
.prompt_order
.iter()
.enumerate()
.map(|(i, k)| (k.as_str(), i))
.collect();
prompt_paths.sort_by_key(|k| rank.get(k.as_str()).copied().unwrap_or(usize::MAX));
if prompt_paths.is_empty() {
if opts.verbose {
eprintln!("No values to prompt for in {}", path.display());
}
if !changed {
return Ok(prompted_values);
}
} else {
for p in prompt_paths {
let default_value = lookup_yaml_path(&root, &p).cloned();
let env_fallback = p
.strip_prefix("environment.")
.and_then(|name| opts.existing_os_env_values.get(name))
.cloned();
let default_text = default_value
.as_ref()
.map(yaml_value_to_string)
.transpose()?
.or(env_fallback);
let context = opts.prompt_contexts.get(&p).map(|s| s.as_str());
let chosen = prompt_for_yaml_key(&p, default_text.as_deref(), context)?;
let chosen_text = yaml_value_to_string(&chosen)?;
prompted_values.push((p.clone(), chosen_text));
set_yaml_path(&mut root, &p, chosen);
changed = true;
}
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
if changed {
let out = serde_yaml::to_string(&root)?;
fs::write(path, out)
.with_context(|| format!("failed to write values file: {}", path.display()))?;
}
Ok(prompted_values)
}
struct PromptUpdateOptions<'a> {
include_environment_vars: bool,
skip_existing_env_vars: &'a BTreeSet<String>,
existing_os_env_values: &'a HashMap<String, String>,
prompt_contexts: &'a HashMap<String, String>,
prompt_order: &'a [String],
force: bool,
verbose: bool,
}
fn collect_prompt_paths(
values_paths: &BTreeSet<String>,
env_vars: &BTreeSet<String>,
include_environment_vars: bool,
skip_existing_env_vars: &BTreeSet<String>,
) -> BTreeSet<String> {
let mut all: BTreeSet<String> = values_paths.clone();
if include_environment_vars {
for var in env_vars {
if skip_existing_env_vars.contains(var) {
continue;
}
all.insert(env_var_values_path(var));
}
}
all
}
fn collect_prompt_contexts(
templates: &[(PathBuf, String)],
re: &Regex,
extended_context: bool,
) -> HashMap<String, String> {
let mut out = HashMap::new();
let include_file_header = templates.len() > 1;
for (path, input) in templates {
for cap in re.captures_iter(input) {
let key = if let Some(p) = cap.get(1) {
p.as_str().to_string()
} else if let Some(env) = extract_env_key(&cap) {
env_var_values_path(env)
} else {
continue;
};
if out.contains_key(&key) {
continue;
}
let mut text = extract_prompt_context(input, &cap, re, &key, extended_context);
if include_file_header {
text = format!("[{}]\n{}", path.display(), text);
}
out.insert(key, text);
}
}
out
}
fn collect_prompt_order(templates: &[(PathBuf, String)], re: &Regex) -> Vec<String> {
let mut out = Vec::new();
let mut seen = BTreeSet::new();
for (_, input) in templates {
for cap in re.captures_iter(input) {
let key = if let Some(p) = cap.get(1) {
p.as_str().to_string()
} else if let Some(env) = extract_env_key(&cap) {
env_var_values_path(env)
} else {
continue;
};
if seen.insert(key.clone()) {
out.push(key);
}
}
}
out
}
fn extract_prompt_context(
input: &str,
caps: ®ex::Captures,
re: &Regex,
key: &str,
extended_context: bool,
) -> String {
let m = if let Some(m) = caps.get(0) {
m
} else {
return String::new();
};
let lines = line_ranges(input);
let line_idx = line_index_for_pos(&lines, m.start()).unwrap_or(0);
if !extended_context {
return trim_line_ending(&input[lines[line_idx].0..lines[line_idx].1]).to_string();
}
let (para_start, para_end) = paragraph_bounds(input, &lines, line_idx);
let para_text = &input[lines[para_start].0..lines[para_end].1];
let keys = collect_prompt_keys(para_text, re);
if keys.len() == 1 && keys.contains(key) {
return trim_surrounding_newlines(para_text).to_string();
}
if let Some((list_start, list_end)) =
list_entry_bounds(input, &lines, line_idx, para_start, para_end)
{
let text = &input[lines[list_start].0..lines[list_end].1];
return trim_surrounding_newlines(text).to_string();
}
trim_line_ending(&input[lines[line_idx].0..lines[line_idx].1]).to_string()
}
fn collect_prompt_keys(text: &str, re: &Regex) -> BTreeSet<String> {
let mut keys = BTreeSet::new();
for cap in re.captures_iter(text) {
if let Some(p) = cap.get(1) {
keys.insert(p.as_str().to_string());
} else if let Some(env) = extract_env_key(&cap) {
keys.insert(env_var_values_path(env));
}
}
keys
}
fn line_ranges(input: &str) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let mut start = 0usize;
for (i, b) in input.bytes().enumerate() {
if b == b'\n' {
ranges.push((start, i + 1));
start = i + 1;
}
}
if start < input.len() {
ranges.push((start, input.len()));
}
if ranges.is_empty() {
ranges.push((0, 0));
}
ranges
}
fn line_index_for_pos(lines: &[(usize, usize)], pos: usize) -> Option<usize> {
lines.iter().position(|(s, e)| *s <= pos && pos < *e)
}
fn paragraph_bounds(input: &str, lines: &[(usize, usize)], line_idx: usize) -> (usize, usize) {
let mut start = line_idx;
while start > 0 {
let prev = trim_line_ending(&input[lines[start - 1].0..lines[start - 1].1]);
if prev.trim().is_empty() {
break;
}
start -= 1;
}
let mut end = line_idx;
while end + 1 < lines.len() {
let next = trim_line_ending(&input[lines[end + 1].0..lines[end + 1].1]);
if next.trim().is_empty() {
break;
}
end += 1;
}
(start, end)
}
fn is_list_item_line(line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with("- ") {
return true;
}
let mut chars = trimmed.chars().peekable();
let mut has_digit = false;
while let Some(c) = chars.peek().copied() {
if c.is_ascii_digit() {
has_digit = true;
chars.next();
} else {
break;
}
}
if !has_digit {
return false;
}
match chars.next() {
Some('.') | Some(')') => matches!(chars.next(), Some(' ')),
_ => false,
}
}
fn leading_spaces(line: &str) -> usize {
line.chars().take_while(|c| *c == ' ').count()
}
fn list_entry_bounds(
input: &str,
lines: &[(usize, usize)],
line_idx: usize,
para_start: usize,
para_end: usize,
) -> Option<(usize, usize)> {
let mut anchor = None;
for idx in (para_start..=line_idx).rev() {
let line = trim_line_ending(&input[lines[idx].0..lines[idx].1]);
if is_list_item_line(line) {
anchor = Some(idx);
break;
}
}
let anchor_idx = anchor?;
let anchor_line = trim_line_ending(&input[lines[anchor_idx].0..lines[anchor_idx].1]);
let anchor_indent = leading_spaces(anchor_line);
let mut end = para_end;
for idx in (anchor_idx + 1)..=para_end {
let line = trim_line_ending(&input[lines[idx].0..lines[idx].1]);
if line.trim().is_empty() {
end = idx.saturating_sub(1);
break;
}
if is_list_item_line(line) && leading_spaces(line) <= anchor_indent {
end = idx.saturating_sub(1);
break;
}
}
Some((anchor_idx, end))
}
fn trim_line_ending(line: &str) -> &str {
line.trim_end_matches(['\r', '\n'])
}
fn trim_surrounding_newlines(s: &str) -> &str {
s.trim_matches(['\r', '\n'])
}
fn env_var_values_path(var: &str) -> String {
format!("environment.{var}")
}
fn values_key_to_env_var(values_key: &str) -> String {
let no_prefix = values_key
.strip_prefix("environment.")
.unwrap_or(values_key);
no_prefix.replace('.', "_").to_uppercase()
}
fn shell_escape_single_quoted(value: &str) -> String {
value.replace('\'', "'\"'\"'")
}
fn render_eval_exports_with_env(
prompted_values: &[(String, String)],
resolved_env_map: &HashMap<String, String>,
) -> String {
let mut export_map: HashMap<String, String> = HashMap::new();
for (key, value) in prompted_values {
let env_name = values_key_to_env_var(key);
export_map.insert(env_name, value.clone());
}
// Always export resolved env placeholders in --eval mode, even without --force.
for (name, value) in resolved_env_map {
export_map.insert(name.clone(), value.clone());
}
let mut names: Vec<String> = export_map.keys().cloned().collect();
names.sort();
let mut out = String::new();
for name in names {
if let Some(value) = export_map.get(&name) {
out.push_str(&format!(
"export {}='{}'\n",
name,
shell_escape_single_quoted(value)
));
}
}
out
}
fn prompted_environment_values(prompted_values: &[(String, String)]) -> HashMap<String, String> {
let mut out = HashMap::new();
for (key, value) in prompted_values {
if let Some(env_name) = key.strip_prefix("environment.") {
out.insert(env_name.to_string(), value.clone());
}
}
out
}
fn indent_multiline_value(value: &str, input: &str, match_start: usize) -> String {
if !value.contains('\n') {
return value.to_string();
}
let line_start = input[..match_start].rfind('\n').map(|i| i + 1).unwrap_or(0);
let before_match = &input[line_start..match_start];
let indent: String = before_match
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let mut out = String::with_capacity(value.len() + indent.len());
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
out.push(ch);
if ch == '\n' && chars.peek().is_some() {
out.push_str(&indent);
}
}
out
}
fn format_replacement_with_indent(
value: &str,
input: &str,
match_start: usize,
match_end: usize,
) -> String {
if !value.contains('\n') {
return value.to_string();
}
if should_use_yaml_block_scalar(input, match_start, match_end) {
format_as_yaml_block_scalar(value, input, match_start)
} else {
indent_multiline_value(value, input, match_start)
}
}
fn should_use_yaml_block_scalar(input: &str, match_start: usize, match_end: usize) -> bool {
let line_start = input[..match_start].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_end = input[match_end..]
.find('\n')
.map(|i| match_end + i)
.unwrap_or(input.len());
let prefix = &input[line_start..match_start];
let suffix = &input[match_end..line_end];
let prefix_trimmed = prefix.trim_end();
let suffix_trimmed = suffix.trim();
(prefix_trimmed.ends_with(':') || prefix_trimmed.ends_with('-')) && suffix_trimmed.is_empty()
}
fn format_as_yaml_block_scalar(value: &str, input: &str, match_start: usize) -> String {
let line_start = input[..match_start].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_prefix = &input[line_start..match_start];
let line_indent: String = line_prefix
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let content_indent = format!("{line_indent} ");
let indicator = if has_trailing_empty_lines(value) {
"|+"
} else {
"|"
};
let content = indent_every_line(value, &content_indent);
format!("{indicator}\n{content}")
}
fn has_trailing_empty_lines(value: &str) -> bool {
let mut trailing_newlines = 0usize;
for ch in value.chars().rev() {
if ch == '\n' {
trailing_newlines += 1;
} else {
break;
}
}
trailing_newlines > 1
}
fn indent_every_line(value: &str, indent: &str) -> String {
let mut out = String::new();
for part in value.split_inclusive('\n') {
if let Some(line) = part.strip_suffix('\n') {
out.push_str(indent);
out.push_str(line);
out.push('\n');
} else {
out.push_str(indent);
out.push_str(part);
}
}
out
}
fn resolve_env_from_values_file(
env_vars: &BTreeSet<String>,
yaml: &YamlValue,
) -> Result<(HashMap<String, String>, Vec<String>)> {
let mut env_map = HashMap::new();
let mut missing_paths = Vec::new();
for var in env_vars {
let path = env_var_values_path(var);
match lookup_yaml_path(yaml, &path) {
Some(v) => {
env_map.insert(var.clone(), yaml_value_to_string(v)?);
}
None => missing_paths.push(path),
}
}
Ok((env_map, missing_paths))
}
fn prompt_for_yaml_key(
path: &str,
default: Option<&str>,
context: Option<&str>,
) -> Result<YamlValue> {
let mut prompt = format!("Enter value for values file key {path}");
if let Some(default_text) = default {
prompt.push_str(&format!(" [{default_text}]"));
}
prompt.push_str(": ");
let mut err = io::stderr().lock();
if let Some(ctx) = context {
err.write_all(b"\n")?;
err.write_all(ctx.as_bytes())?;
err.write_all(b"\n")?;
}
err.write_all(prompt.as_bytes())?;
err.flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let entered = line.trim_end_matches(['\r', '\n']);
if entered.is_empty() {
if let Some(v) = default {
return Ok(YamlValue::String(v.to_string()));
}
return Ok(YamlValue::String(String::new()));
}
Ok(YamlValue::String(entered.to_string()))
}
fn set_yaml_path(root: &mut YamlValue, path: &str, value: YamlValue) {
let parts: Vec<&str> = path.split('.').collect();
if !matches!(root, YamlValue::Mapping(_)) {
*root = YamlValue::Mapping(YamlMapping::new());
}
let mut cur = root;
let mut value_opt = Some(value);
for (idx, part) in parts.iter().enumerate() {
let is_last = idx == parts.len() - 1;
let key = YamlValue::String((*part).to_string());
match cur {
YamlValue::Mapping(map) => {
if is_last {
if let Some(v) = value_opt.take() {
map.insert(key, v);
}
return;
}
let entry = map
.entry(key)
.or_insert_with(|| YamlValue::Mapping(YamlMapping::new()));
if !matches!(entry, YamlValue::Mapping(_)) {
*entry = YamlValue::Mapping(YamlMapping::new());
}
cur = entry;
}
_ => {
*cur = YamlValue::Mapping(YamlMapping::new());
}
}
}
}
fn lookup_yaml_path<'a>(root: &'a YamlValue, path: &str) -> Option<&'a YamlValue> {
// path like "foo.bar.baz"
let mut cur = root;
for part in path.split('.') {
match cur {
YamlValue::Mapping(map) => {
let key = YamlValue::String(part.to_string());
cur = map.get(&key)?;
}
_ => return None,
}
}
Some(cur)
}
fn yaml_value_to_string(v: &YamlValue) -> Result<String> {
Ok(match v {
YamlValue::Null => "".to_string(),
YamlValue::Bool(b) => b.to_string(),
YamlValue::Number(n) => n.to_string(),
YamlValue::String(s) => s.clone(),
// For sequences/maps, serialize to YAML (trim trailing newline).
other => serde_yaml::to_string(other)?.trim_end().to_string(),
})
}
fn write_output(output: Option<&PathBuf>, bytes: &[u8]) -> Result<()> {
match output.map(|p| p.as_path()) {
None => {
let mut out = io::stdout().lock();
out.write_all(bytes)?;
}
Some(p) if p.to_string_lossy() == "-" => {
let mut out = io::stdout().lock();
out.write_all(bytes)?;
}
Some(p) => {
fs::write(p, bytes)
.with_context(|| format!("failed to write output file: {}", p.display()))?;
}
}
Ok(())
}
fn write_outputs(output: Option<&PathBuf>, rendered: &[(PathBuf, String)]) -> Result<()> {
if rendered.len() == 1 {
return write_output(output, rendered[0].1.as_bytes());
}
let merged = render_multi_document_yaml(rendered);
write_output(output, merged.as_bytes())
}
fn render_multi_document_yaml(rendered: &[(PathBuf, String)]) -> String {
let mut out = String::new();
for (idx, (_, content)) in rendered.iter().enumerate() {
if idx > 0 {
out.push_str("\n---\n");
}
out.push_str(content);
if !content.ends_with('\n') {
out.push('\n');
}
}
out
}
fn ensure_all_yaml_files(input_files: &[PathBuf]) -> Result<()> {
for path in input_files {
if !is_yaml_file(path) {
bail!(
"all input files must be *.yaml for multi-file output, but found {}",
path.display()
);
}
}
Ok(())
}
fn is_yaml_file(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".yaml"))
.unwrap_or(false)
}
#[derive(Copy, Clone)]
enum CompletionShell {
Bash,
Zsh,
}
fn install_completion(shell_arg: &str) -> Result<()> {
let shell = resolve_completion_shell(shell_arg)?;
let home = home_dir()?;
match shell {
CompletionShell::Bash => {
let data_home = env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".local/share"));
let target_dir = data_home.join("bash-completion/completions");
fs::create_dir_all(&target_dir)
.with_context(|| format!("failed to create {}", target_dir.display()))?;
let target = target_dir.join("tplenv");
fs::write(&target, BASH_COMPLETION)
.with_context(|| format!("failed to write {}", target.display()))?;
eprintln!("Installed bash completion: {}", target.display());
eprintln!("Open a new shell, or run: source {}", target.display());
}
CompletionShell::Zsh => {
let target_dir = home.join(".zsh/completions");
fs::create_dir_all(&target_dir)
.with_context(|| format!("failed to create {}", target_dir.display()))?;
let target = target_dir.join("_tplenv");
fs::write(&target, ZSH_COMPLETION)
.with_context(|| format!("failed to write {}", target.display()))?;
let zshrc = home.join(".zshrc");
ensure_line_in_file(&zshrc, "fpath=(~/.zsh/completions $fpath)")?;
ensure_line_in_file(&zshrc, "autoload -Uz compinit && compinit")?;
eprintln!("Installed zsh completion: {}", target.display());
eprintln!(
"Open a new shell, or run: fpath=(~/.zsh/completions $fpath); autoload -Uz compinit && compinit"
);
}
}
Ok(())
}
fn resolve_completion_shell(shell_arg: &str) -> Result<CompletionShell> {
if shell_arg == "auto" {
let shell = env::var("SHELL").unwrap_or_default();
let base = Path::new(&shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default();
return match base {
"bash" => Ok(CompletionShell::Bash),
"zsh" => Ok(CompletionShell::Zsh),
_ => bail!(
"could not detect shell from SHELL={shell}; use --install-completion bash|zsh"
),
};
}
match shell_arg {
"bash" => Ok(CompletionShell::Bash),
"zsh" => Ok(CompletionShell::Zsh),
_ => bail!("unsupported shell '{shell_arg}', expected bash or zsh"),
}
}
fn home_dir() -> Result<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("HOME is not set"))
}
fn ensure_line_in_file(path: &Path, line: &str) -> Result<()> {
let content = if path.exists() {
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?
} else {
String::new()
};
if content.lines().any(|l| l == line) {
return Ok(());
}
let mut updated = content;
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(line);
updated.push('\n');
fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_placeholders_finds_unique_env_and_values() {
let input = r#"
apiVersion: v1
metadata:
namespace: {{NAMESPACE}}
name: {{ APP_NAME }}
short_env: $SHORT_ENV
brace_env: ${BRACE_ENV}
spec:
image: {{ .Values.image.repository }}:{{.Values.image.tag}}
replicas: {{ .Values.replicas }}
namespace2: {{NAMESPACE}}
"#;
let re = placeholder_regex().expect("regex must compile");
let (env_vars, values_paths) = collect_placeholders(input, &re);
assert_eq!(
env_vars,
BTreeSet::from([
"APP_NAME".to_string(),
"BRACE_ENV".to_string(),
"NAMESPACE".to_string(),
"SHORT_ENV".to_string()
])
);
assert_eq!(
values_paths,
BTreeSet::from([
"image.repository".to_string(),
"image.tag".to_string(),
"replicas".to_string()
])
);
}
#[test]
fn set_yaml_path_creates_nested_mappings() {
let mut root = YamlValue::Mapping(YamlMapping::new());
set_yaml_path(
&mut root,
"service.port",
YamlValue::Number(serde_yaml::Number::from(8080)),
);
let got = lookup_yaml_path(&root, "service.port");
assert_eq!(
got,
Some(&YamlValue::Number(serde_yaml::Number::from(8080)))
);
}
#[test]
fn set_yaml_path_replaces_non_mapping_intermediate_nodes() {
let mut root: YamlValue = serde_yaml::from_str("service: api\n").expect("valid yaml");
set_yaml_path(
&mut root,
"service.port",
YamlValue::Number(serde_yaml::Number::from(80)),
);
let got = lookup_yaml_path(&root, "service.port");
assert_eq!(got, Some(&YamlValue::Number(serde_yaml::Number::from(80))));
}
#[test]
fn yaml_value_to_string_handles_scalars_and_mappings() {
assert_eq!(
yaml_value_to_string(&YamlValue::Bool(true)).expect("bool string"),
"true"
);
assert_eq!(
yaml_value_to_string(&YamlValue::String("abc".to_string())).expect("string value"),
"abc"
);
let mapping: YamlValue = serde_yaml::from_str("foo: bar\n").expect("valid map yaml");
let rendered = yaml_value_to_string(&mapping).expect("mapping string");
assert!(rendered.contains("foo: bar"));
}
#[test]
fn env_var_values_path_builds_expected_key() {
assert_eq!(env_var_values_path("NAMESPACE"), "environment.NAMESPACE");
}
#[test]
fn resolve_env_from_values_file_reads_environment_section() {
let yaml: YamlValue = serde_yaml::from_str(
r#"
environment:
APP_NAME: api
NAMESPACE: prod
"#,
)
.expect("valid yaml");
let env_vars = BTreeSet::from(["APP_NAME".to_string(), "NAMESPACE".to_string()]);
let (resolved, missing) =
resolve_env_from_values_file(&env_vars, &yaml).expect("env values resolve");
assert_eq!(resolved.get("APP_NAME"), Some(&"api".to_string()));
assert_eq!(resolved.get("NAMESPACE"), Some(&"prod".to_string()));
assert!(missing.is_empty());
}
#[test]
fn resolve_env_from_values_file_reports_missing_keys() {
let yaml: YamlValue = serde_yaml::from_str(
r#"
environment:
APP_NAME: api
"#,
)
.expect("valid yaml");
let env_vars = BTreeSet::from(["APP_NAME".to_string(), "NAMESPACE".to_string()]);
let (resolved, missing) =
resolve_env_from_values_file(&env_vars, &yaml).expect("env values resolve");
assert_eq!(resolved.get("APP_NAME"), Some(&"api".to_string()));
assert!(!resolved.contains_key("NAMESPACE"));
assert_eq!(missing, vec!["environment.NAMESPACE".to_string()]);
}
#[test]
fn file_pattern_regex_supports_num_token_and_wildcard() {
let re = file_pattern_regex("<NUM>-*.yaml").expect("pattern compiles");
assert!(re.is_match("1-demo.yaml"));
assert!(re.is_match("42-x.yaml"));
assert!(!re.is_match("demo.yaml"));
assert!(!re.is_match("a-demo.yaml"));
}
#[test]
fn collect_prompt_paths_deduplicates_shared_keys() {
let values_paths = BTreeSet::from(["db.user".to_string()]);
let env_vars = BTreeSet::from(["APP_NAME".to_string(), "APP_NAME".to_string()]);
let skip = BTreeSet::new();
let all = collect_prompt_paths(&values_paths, &env_vars, true, &skip);
assert!(all.contains("db.user"));
assert!(all.contains("environment.APP_NAME"));
assert_eq!(all.len(), 2);
}
#[test]
fn collect_prompt_paths_skips_existing_env_vars_without_force() {
let values_paths = BTreeSet::new();
let env_vars = BTreeSet::from(["IMAGE".to_string(), "SIGNER".to_string()]);
let skip = BTreeSet::from(["IMAGE".to_string()]);
let all = collect_prompt_paths(&values_paths, &env_vars, true, &skip);
assert!(!all.contains("environment.IMAGE"));
assert!(all.contains("environment.SIGNER"));
}
#[test]
fn extract_prompt_context_defaults_to_single_line() {
let input = "image: ${IMAGE}\n";
let re = placeholder_regex().expect("regex compiles");
let cap = re.captures(input).expect("capture exists");
let got = extract_prompt_context(input, &cap, &re, "environment.IMAGE", false);
assert_eq!(got, "image: ${IMAGE}");
}
#[test]
fn extract_prompt_context_uses_paragraph_for_single_variable() {
let input = "title: ${IMAGE}\nnotes: hello\n\nother: x\n";
let re = placeholder_regex().expect("regex compiles");
let cap = re.captures(input).expect("capture exists");
let got = extract_prompt_context(input, &cap, &re, "environment.IMAGE", true);
assert_eq!(got, "title: ${IMAGE}\nnotes: hello");
}
#[test]
fn extract_prompt_context_uses_list_entry_when_paragraph_has_multiple_variables() {
let input = "items:\n - user: ${A}\n note: x\n - user: ${B}\n";
let re = placeholder_regex().expect("regex compiles");
let cap = re
.captures_iter(input)
.nth(1)
.expect("second placeholder capture");
let got = extract_prompt_context(input, &cap, &re, "environment.B", true);
assert_eq!(got, " - user: ${B}");
}
#[test]
fn collect_prompt_order_follows_file_occurrence() {
let templates = vec![(
PathBuf::from("a.yaml"),
"x: ${B}\ny: {{ .Values.alpha }}\nz: ${A}\n".to_string(),
)];
let re = placeholder_regex().expect("regex compiles");
let order = collect_prompt_order(&templates, &re);
assert_eq!(
order,
vec![
"environment.B".to_string(),
"alpha".to_string(),
"environment.A".to_string()
]
);
}
#[test]
fn resolve_completion_shell_parses_explicit_values() {
assert!(matches!(
resolve_completion_shell("bash").expect("bash shell"),
CompletionShell::Bash
));
assert!(matches!(
resolve_completion_shell("zsh").expect("zsh shell"),
CompletionShell::Zsh
));
assert!(resolve_completion_shell("fish").is_err());
}
#[test]
fn is_yaml_file_only_accepts_yaml_suffix() {
assert!(is_yaml_file(Path::new("1-a.yaml")));
assert!(!is_yaml_file(Path::new("1-a.yml")));
assert!(!is_yaml_file(Path::new("1-a.txt")));
}
#[test]
fn render_multi_document_yaml_uses_doc_separator() {
let rendered = vec![
(PathBuf::from("1-a.yaml"), "a: 1\n".to_string()),
(PathBuf::from("2-b.yaml"), "b: 2\n".to_string()),
];
let out = render_multi_document_yaml(&rendered);
assert_eq!(out, "a: 1\n\n---\nb: 2\n");
}
#[test]
fn extract_env_key_supports_three_env_styles() {
let re = placeholder_regex().expect("regex compiles");
let c1 = re
.captures("{{NAMESPACE}}")
.expect("must capture handlebars env");
assert_eq!(extract_env_key(&c1), Some("NAMESPACE"));
let c2 = re.captures("${APP_NAME}").expect("must capture brace env");
assert_eq!(extract_env_key(&c2), Some("APP_NAME"));
let c3 = re.captures("$REGION").expect("must capture short env");
assert_eq!(extract_env_key(&c3), Some("REGION"));
}
#[test]
fn values_key_to_env_var_handles_environment_prefix_and_dots() {
assert_eq!(values_key_to_env_var("environment.APP_NAME"), "APP_NAME");
assert_eq!(values_key_to_env_var("image.tag"), "IMAGE_TAG");
}
#[test]
fn render_eval_exports_outputs_bash_exports() {
let prompted = vec![
("environment.APP_NAME".to_string(), "demo-app".to_string()),
("image.tag".to_string(), "1.2.3".to_string()),
];
let out = render_eval_exports_with_env(&prompted, &HashMap::new());
assert!(out.contains("export APP_NAME='demo-app'"));
assert!(out.contains("export IMAGE_TAG='1.2.3'"));
}
#[test]
fn render_eval_exports_with_env_always_includes_resolved_env_values() {
let prompted = vec![("image.tag".to_string(), "1.2.3".to_string())];
let resolved_env = HashMap::from([("IMAGE".to_string(), "repo/app:7".to_string())]);
let out = render_eval_exports_with_env(&prompted, &resolved_env);
assert!(out.contains("export IMAGE='repo/app:7'"));
assert!(out.contains("export IMAGE_TAG='1.2.3'"));
}
#[test]
fn prompted_environment_values_only_keeps_environment_entries() {
let prompted = vec![
("environment.IMAGE".to_string(), "nginx:1.2".to_string()),
("db.user".to_string(), "app".to_string()),
];
let out = prompted_environment_values(&prompted);
assert_eq!(out.get("IMAGE"), Some(&"nginx:1.2".to_string()));
assert!(!out.contains_key("DB_USER"));
}
#[test]
fn indent_multiline_value_uses_placeholder_line_indent() {
let input = "data:\n script: |\n {{ .Values.script }}\n";
let match_start = input
.find("{{ .Values.script }}")
.expect("placeholder should exist");
let value = "echo first\necho second";
let out = indent_multiline_value(value, input, match_start);
assert_eq!(out, "echo first\n echo second");
}
#[test]
fn format_replacement_with_indent_uses_yaml_block_scalar_for_inline_value() {
let input = "data:\n script: {{ .Values.script }}\n";
let token = "{{ .Values.script }}";
let match_start = input.find(token).expect("placeholder should exist");
let match_end = match_start + token.len();
let value = "echo first\necho second";
let out = format_replacement_with_indent(value, input, match_start, match_end);
assert_eq!(out, "|\n echo first\n echo second");
}
#[test]
fn format_replacement_with_indent_uses_block_scalar_keep_for_trailing_empty_lines() {
let input = "data:\n script: {{ .Values.script }}\n";
let token = "{{ .Values.script }}";
let match_start = input.find(token).expect("placeholder should exist");
let match_end = match_start + token.len();
let value = "echo first\n\n";
let out = format_replacement_with_indent(value, input, match_start, match_end);
assert_eq!(out, "|+\n echo first\n \n");
}
#[test]
fn indent_multiline_signer_in_yaml_list_items_stays_valid_yaml() {
let input = r#"name: kbs-certs
version: "0.3.11"
access_policy:
read:
- ANY
update:
- ${SIGNER}
create_sessions:
- ${SIGNER}
"#;
let signer = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtestkeyline\n-----END PUBLIC KEY-----";
let re = placeholder_regex().expect("regex compiles");
let rendered = re.replace_all(input, |caps: ®ex::Captures| {
if let Some(key) = extract_env_key(caps)
&& key == "SIGNER"
{
let m = caps.get(0).expect("full match present");
return format_replacement_with_indent(signer, input, m.start(), m.end());
}
caps.get(0)
.map(|m| m.as_str().to_string())
.unwrap_or_default()
});
let rendered = rendered.to_string();
assert!(rendered.contains("- |\n -----BEGIN PUBLIC KEY-----"));
assert_eq!(rendered.matches("- |").count(), 2);
let parsed: YamlValue = serde_yaml::from_str(&rendered).expect("rendered yaml is valid");
assert!(matches!(parsed, YamlValue::Mapping(_)));
}
}