use std::collections::BTreeSet;
use std::fmt::Write;
use std::path::Path;
use anyhow::{bail, Result};
use console::style;
use crate::config::{Config, GenerateFormat, GenerateOutput};
use crate::validate::Format;
struct SecretMeta {
key: String,
description: Option<String>,
format: Option<Format>,
optional: bool,
enum_values: Option<Vec<String>>,
}
struct GenerateResult {
relative_path: String,
secret_count: usize,
gitignore_warning: bool,
}
struct GenerateReport {
results: Vec<GenerateResult>,
}
impl GenerateReport {
fn render(&self) -> Result<()> {
if self.results.is_empty() {
cliclack::log::warning("No secrets defined in config")?;
return Ok(());
}
for result in &self.results {
cliclack::log::success(format!(
"Wrote {} secrets to {}",
result.secret_count, result.relative_path
))?;
if result.gitignore_warning {
cliclack::log::info(format!(
"Consider adding {} to .gitignore",
result.relative_path
))?;
}
}
Ok(())
}
}
pub fn run(
config: &Config,
format: Option<&GenerateFormat>,
output: Option<&str>,
preview: bool,
) -> Result<()> {
let metas = collect_secret_metas(config);
if metas.is_empty() {
if preview {
return Ok(());
}
let report = GenerateReport {
results: Vec::new(),
};
return report.render();
}
let outputs = resolve_outputs(format, output, &config.generate)?;
if !preview {
cliclack::intro(
style(format!(
"{} · {} output{}",
style(&config.project).bold(),
outputs.len(),
if outputs.len() == 1 { "" } else { "s" },
))
.to_string(),
)?;
}
if preview {
for (i, entry) in outputs.iter().enumerate() {
if i > 0 {
println!();
}
if outputs.len() > 1 {
let name = entry
.output
.as_deref()
.unwrap_or(entry.format.default_output());
println!("── {name} ──");
}
let content = render_content(&metas, entry.format);
print!("{content}");
}
return Ok(());
}
let mut results = Vec::new();
for entry in &outputs {
results.push(generate_one(config, &metas, entry)?);
}
let report = GenerateReport { results };
report.render()?;
let file_count = report.results.len();
let secret_count = report.results.first().map_or(0, |r| r.secret_count);
cliclack::outro(
style(format!(
"Generated {} file{} from {} secret{}",
file_count,
if file_count == 1 { "" } else { "s" },
secret_count,
if secret_count == 1 { "" } else { "s" },
))
.dim()
.to_string(),
)?;
Ok(())
}
fn resolve_outputs(
format: Option<&GenerateFormat>,
output: Option<&str>,
config_generate: &[GenerateOutput],
) -> Result<Vec<GenerateOutput>> {
match (format, output) {
(Some(f), out) => Ok(vec![GenerateOutput {
format: *f,
output: out.map(String::from),
}]),
(None, Some(_)) => {
bail!("--output requires a format argument (e.g. `esk generate dts --output path`)");
}
(None, None) if !config_generate.is_empty() => Ok(config_generate.to_vec()),
(None, None) => {
bail!(
"No format specified and no generate outputs configured in esk.yaml\n\n\
Usage: esk generate <FORMAT> [--output <path>]\n\n\
Available formats:\n \
dts TypeScript declaration file (env.d.ts)\n \
ts Runtime TypeScript module (env.ts)\n \
env-example Example .env file (.env.example)\n\n\
Or configure outputs in esk.yaml:\n \
generate:\n \
- format: dts\n \
- format: env-example\n \
output: config/.env.example"
);
}
}
}
const GENERATED_HEADER: &str = "// Generated by esk";
fn is_esk_generated(path: &Path) -> bool {
std::fs::read_to_string(path)
.map(|c| c.starts_with(GENERATED_HEADER))
.unwrap_or(false)
}
fn render_content(metas: &[SecretMeta], format: GenerateFormat) -> String {
match format {
GenerateFormat::Dts => generate_dts(metas),
GenerateFormat::Ts => generate_runtime(metas),
GenerateFormat::EnvExample => generate_env_example(metas),
}
}
fn generate_one(
config: &Config,
metas: &[SecretMeta],
entry: &GenerateOutput,
) -> Result<GenerateResult> {
let default_name = entry.format.default_output();
let out_path = match &entry.output {
Some(p) => config.root.join(p),
None => config.root.join(default_name),
};
if out_path.exists() && !is_esk_generated(&out_path) {
let relative = out_path.strip_prefix(&config.root).unwrap_or(&out_path);
bail!(
"{} already exists and was not generated by esk.\n\
Use --output to write to a different path:\n\n \
esk generate {} --output esk-{}",
relative.display(),
entry.format.cli_name(),
default_name,
);
}
let content = render_content(metas, entry.format);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, content)?;
let relative = out_path.strip_prefix(&config.root).unwrap_or(&out_path);
let gitignore_warning =
entry.format.should_warn_gitignore() && !is_gitignored(&config.root, relative);
Ok(GenerateResult {
relative_path: relative.display().to_string(),
secret_count: metas.len(),
gitignore_warning,
})
}
fn collect_secret_metas(config: &Config) -> Vec<SecretMeta> {
let mut seen = BTreeSet::new();
let mut metas = Vec::new();
for group in config.secrets.values() {
for (key, def) in group {
if seen.insert(key.clone()) {
let (format, optional, enum_values) = match &def.validate {
Some(v) => {
let enums = v
.enum_values
.as_ref()
.and_then(|raw| crate::validate::resolve_enum_values(raw).ok());
(v.format, v.optional, enums)
}
None => (None, false, None),
};
metas.push(SecretMeta {
key: key.clone(),
description: def.description.clone(),
format,
optional,
enum_values,
});
}
}
}
metas.sort_by(|a, b| a.key.cmp(&b.key));
metas
}
fn generate_dts(metas: &[SecretMeta]) -> String {
let mut out = String::from("// Generated by esk — do not edit\n");
out.push_str("declare namespace NodeJS {\n");
out.push_str(" interface ProcessEnv {\n");
for m in metas {
if let Some(ref values) = m.enum_values {
let union = values
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(" | ");
if m.optional {
let _ = writeln!(out, " {}?: {} | undefined;", m.key, union);
} else {
let _ = writeln!(out, " {}: {};", m.key, union);
}
} else if m.optional {
let _ = writeln!(out, " {}?: string | undefined;", m.key);
} else {
let _ = writeln!(out, " {}: string;", m.key);
}
}
out.push_str(" }\n");
out.push_str("}\n");
out
}
fn generate_runtime(metas: &[SecretMeta]) -> String {
let mut out = String::from("// Generated by esk — do not edit\n");
let mut needed_helpers: BTreeSet<&str> = BTreeSet::new();
for m in metas {
if m.optional {
continue;
}
match m.format {
Some(Format::Integer) => {
needed_helpers.insert("envInt");
}
Some(Format::Number) => {
needed_helpers.insert("envFloat");
}
Some(Format::Boolean) => {
needed_helpers.insert("envBool");
}
Some(Format::Json) => {
needed_helpers.insert("envJson");
}
_ => {
needed_helpers.insert("requireEnv");
}
}
}
if needed_helpers.contains("requireEnv") {
out.push_str("function requireEnv(key: string): string {\n");
out.push_str(" const value = process.env[key];\n");
out.push_str(" if (value === undefined || value === \"\") {\n");
out.push_str(" throw new Error(`Missing required environment variable: ${key}`);\n");
out.push_str(" }\n");
out.push_str(" return value;\n");
out.push_str("}\n");
out.push('\n');
}
if needed_helpers.contains("envInt") {
out.push_str("function envInt(key: string): number {\n");
out.push_str(" const value = process.env[key];\n");
out.push_str(" if (value === undefined || value === \"\") {\n");
out.push_str(" throw new Error(`Missing required environment variable: ${key}`);\n");
out.push_str(" }\n");
out.push_str(" const num = parseInt(value, 10);\n");
out.push_str(" if (isNaN(num)) {\n");
out.push_str(" throw new Error(`Expected integer for ${key}`);\n");
out.push_str(" }\n");
out.push_str(" return num;\n");
out.push_str("}\n");
out.push('\n');
}
if needed_helpers.contains("envFloat") {
out.push_str("function envFloat(key: string): number {\n");
out.push_str(" const value = process.env[key];\n");
out.push_str(" if (value === undefined || value === \"\") {\n");
out.push_str(" throw new Error(`Missing required environment variable: ${key}`);\n");
out.push_str(" }\n");
out.push_str(" const num = parseFloat(value);\n");
out.push_str(" if (isNaN(num)) {\n");
out.push_str(" throw new Error(`Expected number for ${key}`);\n");
out.push_str(" }\n");
out.push_str(" return num;\n");
out.push_str("}\n");
out.push('\n');
}
if needed_helpers.contains("envBool") {
out.push_str("function envBool(key: string): boolean {\n");
out.push_str(" const value = process.env[key]?.toLowerCase();\n");
out.push_str(" if (value === undefined || value === \"\") {\n");
out.push_str(" throw new Error(`Missing required environment variable: ${key}`);\n");
out.push_str(" }\n");
out.push_str(" return [\"true\", \"1\", \"yes\"].includes(value);\n");
out.push_str("}\n");
out.push('\n');
}
if needed_helpers.contains("envJson") {
out.push_str("function envJson(key: string): unknown {\n");
out.push_str(" const value = process.env[key];\n");
out.push_str(" if (value === undefined || value === \"\") {\n");
out.push_str(" throw new Error(`Missing required environment variable: ${key}`);\n");
out.push_str(" }\n");
out.push_str(" try {\n");
out.push_str(" return JSON.parse(value);\n");
out.push_str(" } catch {\n");
out.push_str(" throw new Error(`Invalid JSON for environment variable ${key}`);\n");
out.push_str(" }\n");
out.push_str("}\n");
out.push('\n');
}
out.push_str("export const env = {\n");
for m in metas {
if m.optional {
let _ = writeln!(out, " {}: process.env.{},", m.key, m.key);
} else {
match m.format {
Some(Format::Integer) => {
let _ = writeln!(out, " {}: envInt(\"{}\"),", m.key, m.key);
}
Some(Format::Number) => {
let _ = writeln!(out, " {}: envFloat(\"{}\"),", m.key, m.key);
}
Some(Format::Boolean) => {
let _ = writeln!(out, " {}: envBool(\"{}\"),", m.key, m.key);
}
Some(Format::Json) => {
let _ = writeln!(out, " {}: envJson(\"{}\"),", m.key, m.key);
}
_ => {
let _ = writeln!(out, " {}: requireEnv(\"{}\"),", m.key, m.key);
}
}
}
}
out.push_str("} as const;\n");
out
}
fn generate_env_example(metas: &[SecretMeta]) -> String {
let mut out = String::from("# Generated by esk — do not edit\n");
for (i, m) in metas.iter().enumerate() {
if i > 0 {
out.push('\n');
}
if let Some(ref desc) = m.description {
for line in desc.lines() {
let _ = writeln!(out, "# {line}");
}
}
if let Some(ref values) = m.enum_values {
let _ = writeln!(out, "# Allowed: {}", values.join(", "));
}
if m.optional {
out.push_str("# Optional\n");
let _ = writeln!(out, "# {}=", m.key);
} else {
let _ = writeln!(out, "{}=", m.key);
}
}
out
}
fn is_gitignored(root: &Path, relative: &Path) -> bool {
let gitignore_path = root.join(".gitignore");
let Ok(content) = std::fs::read_to_string(&gitignore_path) else {
return false;
};
let filename = relative.file_name().unwrap_or_default().to_string_lossy();
let full_path = relative.to_string_lossy();
content.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& (pattern_matches(trimmed, &filename) || pattern_matches(trimmed, &full_path))
})
}
fn pattern_matches(pattern: &str, path: &str) -> bool {
if let Some(suffix) = pattern.strip_prefix('*') {
path.ends_with(suffix)
} else if let Some(prefix) = pattern.strip_suffix('*') {
path.starts_with(prefix)
} else {
pattern == path
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Required, TargetsConfig};
use std::collections::BTreeMap;
fn collect_keys(config: &Config) -> BTreeSet<String> {
collect_secret_metas(config)
.into_iter()
.map(|m| m.key)
.collect()
}
fn config_with_secrets(
secrets: BTreeMap<String, BTreeMap<String, crate::config::SecretDef>>,
) -> Config {
Config {
project: "test".to_string(),
environments: vec!["dev".to_string(), "prod".to_string()],
apps: BTreeMap::new(),
targets: TargetsConfig::default(),
remotes: BTreeMap::new(),
secrets,
generate: Vec::new(),
root: std::path::PathBuf::from("/tmp"),
typed_remotes: Vec::new(),
typed_targets: Vec::new(),
}
}
fn secret_def() -> crate::config::SecretDef {
crate::config::SecretDef {
description: None,
targets: BTreeMap::new(),
validate: None,
required: Required::default(),
allow_empty: false,
}
}
fn secret_def_with_desc(desc: &str) -> crate::config::SecretDef {
crate::config::SecretDef {
description: Some(desc.to_string()),
targets: BTreeMap::new(),
validate: None,
required: Required::default(),
allow_empty: false,
}
}
fn secret_def_with_validate(v: crate::validate::Validation) -> crate::config::SecretDef {
crate::config::SecretDef {
description: None,
targets: BTreeMap::new(),
validate: Some(v),
required: Required::default(),
allow_empty: false,
}
}
#[test]
fn collect_keys_unions_across_groups() {
let mut secrets = BTreeMap::new();
let mut group_a = BTreeMap::new();
group_a.insert("ALPHA".to_string(), secret_def());
group_a.insert("SHARED".to_string(), secret_def());
secrets.insert("A".to_string(), group_a);
let mut group_b = BTreeMap::new();
group_b.insert("BETA".to_string(), secret_def());
group_b.insert("SHARED".to_string(), secret_def());
secrets.insert("B".to_string(), group_b);
let config = config_with_secrets(secrets);
let keys = collect_keys(&config);
assert_eq!(keys.len(), 3);
assert!(keys.contains("ALPHA"));
assert!(keys.contains("BETA"));
assert!(keys.contains("SHARED"));
}
#[test]
fn collect_keys_empty_config() {
let config = config_with_secrets(BTreeMap::new());
assert!(collect_keys(&config).is_empty());
}
#[test]
fn dts_output_format() {
let metas = vec![
SecretMeta {
key: "A_KEY".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
SecretMeta {
key: "B_KEY".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
];
let output = generate_dts(&metas);
assert!(output.starts_with("// Generated by esk"));
assert!(output.contains("declare namespace NodeJS"));
assert!(output.contains("interface ProcessEnv"));
assert!(output.contains("A_KEY: string;"));
assert!(output.contains("B_KEY: string;"));
}
#[test]
fn runtime_output_format() {
let metas = vec![SecretMeta {
key: "DB_URL".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
}];
let output = generate_runtime(&metas);
assert!(output.starts_with("// Generated by esk"));
assert!(output.contains("function requireEnv"));
assert!(output.contains("export const env ="));
assert!(output.contains("DB_URL: requireEnv(\"DB_URL\")"));
assert!(output.contains("as const;"));
}
#[test]
fn keys_are_sorted() {
let metas = vec![
SecretMeta {
key: "ZEBRA".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
SecretMeta {
key: "ALPHA".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
SecretMeta {
key: "MIDDLE".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
];
let mut sorted = metas;
sorted.sort_by(|a, b| a.key.cmp(&b.key));
let output = generate_dts(&sorted);
let alpha_pos = output.find("ALPHA").unwrap();
let middle_pos = output.find("MIDDLE").unwrap();
let zebra_pos = output.find("ZEBRA").unwrap();
assert!(alpha_pos < middle_pos);
assert!(middle_pos < zebra_pos);
}
#[test]
fn gitignore_match() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "env.d.ts\n").unwrap();
assert!(is_gitignored(dir.path(), Path::new("env.d.ts")));
assert!(!is_gitignored(dir.path(), Path::new("other.ts")));
}
#[test]
fn gitignore_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_gitignored(dir.path(), Path::new("env.d.ts")));
}
#[test]
fn gitignore_glob_patterns() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "*.d.ts\n").unwrap();
assert!(is_gitignored(dir.path(), Path::new("env.d.ts")));
assert!(!is_gitignored(dir.path(), Path::new("env.ts")));
}
#[test]
fn gitignore_matches_filename_in_subpath() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "env.d.ts\n").unwrap();
assert!(is_gitignored(dir.path(), Path::new("types/env.d.ts")));
}
#[test]
fn gitignore_comments_and_blanks_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "# comment\n\nenv.d.ts\n").unwrap();
assert!(is_gitignored(dir.path(), Path::new("env.d.ts")));
assert!(!is_gitignored(dir.path(), Path::new("# comment")));
}
#[test]
fn generate_dts_enum_union_type() {
let metas = vec![SecretMeta {
key: "NODE_ENV".to_string(),
description: None,
format: None,
optional: false,
enum_values: Some(vec![
"development".to_string(),
"staging".to_string(),
"production".to_string(),
]),
}];
let output = generate_dts(&metas);
assert!(output.contains("NODE_ENV: \"development\" | \"staging\" | \"production\";"));
}
#[test]
fn generate_dts_optional_field() {
let metas = vec![SecretMeta {
key: "FEATURE_FLAG".to_string(),
description: None,
format: Some(Format::String),
optional: true,
enum_values: None,
}];
let output = generate_dts(&metas);
assert!(output.contains("FEATURE_FLAG?: string | undefined;"));
}
#[test]
fn generate_runtime_emits_typed_helpers() {
let metas = vec![
SecretMeta {
key: "PORT".to_string(),
description: None,
format: Some(Format::Integer),
optional: false,
enum_values: None,
},
SecretMeta {
key: "URL".to_string(),
description: None,
format: Some(Format::Url),
optional: false,
enum_values: None,
},
];
let output = generate_runtime(&metas);
assert!(output.contains("function envInt("));
assert!(output.contains("PORT: envInt(\"PORT\")"));
assert!(output.contains("function requireEnv("));
assert!(output.contains("URL: requireEnv(\"URL\")"));
}
#[test]
fn generate_runtime_omits_unused_helpers() {
let metas = vec![SecretMeta {
key: "PORT".to_string(),
description: None,
format: Some(Format::Integer),
optional: false,
enum_values: None,
}];
let output = generate_runtime(&metas);
assert!(output.contains("function envInt("));
assert!(!output.contains("function requireEnv("));
assert!(!output.contains("function envBool("));
assert!(!output.contains("function envFloat("));
assert!(!output.contains("function envJson("));
}
#[test]
fn generate_runtime_optional_uses_process_env() {
let metas = vec![SecretMeta {
key: "FEATURE".to_string(),
description: None,
format: Some(Format::Boolean),
optional: true,
enum_values: None,
}];
let output = generate_runtime(&metas);
assert!(output.contains("FEATURE: process.env.FEATURE"));
assert!(!output.contains("function envBool("));
}
#[test]
fn collect_secret_metas_extracts_validation() {
let mut secrets = BTreeMap::new();
let mut group = BTreeMap::new();
group.insert(
"PORT".to_string(),
secret_def_with_validate(crate::validate::Validation {
format: Some(Format::Integer),
..Default::default()
}),
);
group.insert("PLAIN".to_string(), secret_def());
secrets.insert("General".to_string(), group);
let config = config_with_secrets(secrets);
let metas = collect_secret_metas(&config);
let plain = metas.iter().find(|m| m.key == "PLAIN").unwrap();
assert!(plain.format.is_none());
let port = metas.iter().find(|m| m.key == "PORT").unwrap();
assert_eq!(port.format, Some(Format::Integer));
}
#[test]
fn env_example_basic() {
let metas = vec![
SecretMeta {
key: "A_KEY".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
SecretMeta {
key: "B_KEY".to_string(),
description: None,
format: None,
optional: false,
enum_values: None,
},
];
let output = generate_env_example(&metas);
assert!(output.starts_with("# Generated by esk"));
assert!(output.contains("A_KEY=\n"));
assert!(output.contains("B_KEY=\n"));
}
#[test]
fn env_example_with_descriptions() {
let mut secrets = BTreeMap::new();
let mut group = BTreeMap::new();
group.insert(
"STRIPE_KEY".to_string(),
secret_def_with_desc("Your Stripe API key"),
);
secrets.insert("General".to_string(), group);
let config = config_with_secrets(secrets);
let metas = collect_secret_metas(&config);
let output = generate_env_example(&metas);
assert!(output.contains("# Your Stripe API key\n"));
assert!(output.contains("STRIPE_KEY=\n"));
}
#[test]
fn env_example_with_enums() {
let metas = vec![SecretMeta {
key: "NODE_ENV".to_string(),
description: None,
format: None,
optional: false,
enum_values: Some(vec![
"development".to_string(),
"staging".to_string(),
"production".to_string(),
]),
}];
let output = generate_env_example(&metas);
assert!(output.contains("# Allowed: development, staging, production\n"));
assert!(output.contains("NODE_ENV=\n"));
}
#[test]
fn env_example_optional_commented() {
let metas = vec![SecretMeta {
key: "FEATURE_FLAG".to_string(),
description: None,
format: None,
optional: true,
enum_values: None,
}];
let output = generate_env_example(&metas);
assert!(output.contains("# Optional\n"));
assert!(output.contains("# FEATURE_FLAG=\n"));
assert_eq!(output.matches("FEATURE_FLAG=").count(), 1);
assert!(output.contains("# FEATURE_FLAG="));
}
#[test]
fn resolve_outputs_explicit_format() {
let result = resolve_outputs(Some(&GenerateFormat::Ts), None, &[]).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].format, GenerateFormat::Ts);
assert!(result[0].output.is_none());
}
#[test]
fn resolve_outputs_explicit_format_with_output() {
let result =
resolve_outputs(Some(&GenerateFormat::Dts), Some("types/env.d.ts"), &[]).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].format, GenerateFormat::Dts);
assert_eq!(result[0].output.as_deref(), Some("types/env.d.ts"));
}
#[test]
fn resolve_outputs_config_driven() {
let config_entries = vec![
GenerateOutput {
format: GenerateFormat::Dts,
output: None,
},
GenerateOutput {
format: GenerateFormat::EnvExample,
output: None,
},
];
let result = resolve_outputs(None, None, &config_entries).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].format, GenerateFormat::Dts);
assert_eq!(result[1].format, GenerateFormat::EnvExample);
}
#[test]
fn resolve_outputs_no_format_no_config_errors() {
let err = resolve_outputs(None, None, &[]).unwrap_err();
assert!(err.to_string().contains("No format specified"));
assert!(err.to_string().contains("Available formats"));
}
#[test]
fn resolve_outputs_output_without_format_errors() {
let err = resolve_outputs(None, Some("out.ts"), &[]).unwrap_err();
assert!(err.to_string().contains("--output requires a format"));
}
#[test]
fn collect_secret_metas_extracts_description() {
let mut secrets = BTreeMap::new();
let mut group = BTreeMap::new();
group.insert("MY_KEY".to_string(), secret_def_with_desc("A description"));
group.insert("NO_DESC".to_string(), secret_def());
secrets.insert("General".to_string(), group);
let config = config_with_secrets(secrets);
let metas = collect_secret_metas(&config);
let my_key = metas.iter().find(|m| m.key == "MY_KEY").unwrap();
assert_eq!(my_key.description.as_deref(), Some("A description"));
let no_desc = metas.iter().find(|m| m.key == "NO_DESC").unwrap();
assert!(no_desc.description.is_none());
}
#[test]
fn should_warn_gitignore_true_for_dts() {
assert!(GenerateFormat::Dts.should_warn_gitignore());
}
#[test]
fn should_warn_gitignore_true_for_ts() {
assert!(GenerateFormat::Ts.should_warn_gitignore());
}
#[test]
fn should_warn_gitignore_false_for_env_example() {
assert!(!GenerateFormat::EnvExample.should_warn_gitignore());
}
#[test]
fn is_esk_generated_true() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("env.d.ts");
std::fs::write(&path, "// Generated by esk — do not edit\nstuff").unwrap();
assert!(is_esk_generated(&path));
}
#[test]
fn is_esk_generated_false_for_user_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("env.d.ts");
std::fs::write(
&path,
"declare namespace NodeJS {\n interface ProcessEnv {}\n}",
)
.unwrap();
assert!(!is_esk_generated(&path));
}
#[test]
fn is_esk_generated_false_for_missing_file() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_esk_generated(&dir.path().join("nope.ts")));
}
#[test]
fn env_example_multiline_description() {
let metas = vec![SecretMeta {
key: "DB_URL".to_string(),
description: Some("Connection string\nfor the database".to_string()),
format: None,
optional: false,
enum_values: None,
}];
let output = generate_env_example(&metas);
assert!(output.contains("# Connection string\n# for the database\n"));
}
}