use crate::error::{Error, Result};
use crate::keymanager::KeyStore;
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
#[derive(Debug)]
pub struct Resolution {
pub placeholder: String,
pub key_name: Option<String>,
pub alternatives: Vec<String>,
}
#[derive(Debug)]
pub struct GenResult {
pub content: String,
pub resolutions: Vec<Resolution>,
}
const ENV_VAR_MAP: &[(&str, &str)] = &[
("OPENAI_API_KEY", "openai"),
("ANTHROPIC_API_KEY", "anthropic"),
("GOOGLE_API_KEY", "google"),
("MISTRAL_API_KEY", "mistral"),
("COHERE_API_KEY", "cohere"),
("GROQ_API_KEY", "groq"),
("PERPLEXITY_API_KEY", "perplexity"),
("FIREWORKS_API_KEY", "fireworks"),
("TOGETHER_API_KEY", "together"),
("REPLICATE_API_KEY", "replicate"),
("HUGGINGFACE_API_KEY", "huggingface"),
("DEEPSEEK_API_KEY", "deepseek"),
("XAI_API_KEY", "xai"),
("AZURE_OPENAI_API_KEY", "azure-openai"),
("AWS_API_KEY", "aws"),
("VOYAGE_API_KEY", "voyage"),
("ANYSCALE_API_KEY", "anyscale"),
];
pub fn key_to_env_var(key_name: &str) -> String {
let provider = key_name.split(':').next().unwrap_or(key_name);
for &(env_var, prov) in ENV_VAR_MAP {
if prov == provider {
return env_var.to_string();
}
}
key_name.to_uppercase().replace(':', "_")
}
pub fn generate(
store: &impl KeyStore,
template_path: &Path,
output_path: &Path,
) -> Result<GenResult> {
let content = fs::read_to_string(template_path).map_err(|e| {
Error::Template(format!(
"Cannot read template '{}': {}",
template_path.display(),
e
))
})?;
let result = if is_json_template(&content) {
generate_json(store, &content)?
} else {
generate_env(store, &content)?
};
write_secure(output_path, &result.content)?;
Ok(result)
}
pub fn check_gitignore(path: &Path) -> Option<bool> {
let output = std::process::Command::new("git")
.args(["check-ignore", "-q"])
.arg(path)
.output()
.ok()?;
if output.status.code() == Some(128) {
return None;
}
Some(output.status.success())
}
fn generate_env(store: &impl KeyStore, content: &str) -> Result<GenResult> {
let entries = store.list(false)?;
let provider_map = build_provider_map(&entries);
let mut output = String::new();
let mut resolutions = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
output.push_str(line);
output.push('\n');
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let var_name = trimmed[..eq_pos].trim();
if let Some((key_name, value, alternatives)) =
resolve_env_var(store, var_name, &provider_map)
{
output.push_str(&format!("{}={}\n", var_name, &*value));
resolutions.push(Resolution {
placeholder: var_name.to_string(),
key_name: Some(key_name),
alternatives,
});
} else {
output.push_str(line);
output.push('\n');
resolutions.push(Resolution {
placeholder: var_name.to_string(),
key_name: None,
alternatives: vec![],
});
}
} else {
output.push_str(line);
output.push('\n');
}
}
Ok(GenResult {
content: output,
resolutions,
})
}
fn build_provider_map(
entries: &[crate::keymanager::KeyEntry],
) -> BTreeMap<String, (String, Vec<String>)> {
let mut map: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
for entry in entries {
map.entry(entry.provider.clone())
.and_modify(|(_, alternatives)| alternatives.push(entry.name.clone()))
.or_insert_with(|| (entry.name.clone(), vec![entry.name.clone()]));
}
map
}
fn resolve_env_var(
store: &impl KeyStore,
var_name: &str,
provider_map: &BTreeMap<String, (String, Vec<String>)>,
) -> Option<(String, zeroize::Zeroizing<String>, Vec<String>)> {
let var_upper = var_name.to_uppercase();
for &(env_var, provider) in ENV_VAR_MAP {
if var_upper == env_var
&& let Some((key_name, alternatives)) = provider_map.get(provider)
&& let Ok((value, _)) = store.get(key_name)
{
return Some((key_name.clone(), value, alternatives.clone()));
}
}
None
}
fn generate_json(store: &impl KeyStore, content: &str) -> Result<GenResult> {
let mut output = content.to_string();
let mut resolutions = Vec::new();
let mut search_from = 0;
while let Some(pos) = output[search_from..].find("{{lkr:") {
let start = search_from + pos;
let end = match output[start..].find("}}") {
Some(pos) => start + pos + 2,
None => {
return Err(Error::Template(format!(
"Unclosed placeholder starting at position {}",
start
)));
}
};
let placeholder = output[start..end].to_string();
let key_name = placeholder[6..placeholder.len() - 2].to_string();
match store.get(&key_name) {
Ok((value, kind)) => {
if kind == crate::keymanager::KeyKind::Admin {
return Err(Error::Template(format!(
"Admin key '{}' cannot be used in templates. Only runtime keys are allowed.",
key_name
)));
}
let escaped = escape_json_value(&value);
output = format!("{}{}{}", &output[..start], escaped, &output[end..]);
resolutions.push(Resolution {
placeholder,
key_name: Some(key_name),
alternatives: vec![], });
search_from = start + escaped.len();
}
Err(Error::KeyNotFound { .. }) => {
resolutions.push(Resolution {
placeholder,
key_name: None,
alternatives: vec![],
});
search_from = end;
}
Err(e) => return Err(e),
}
}
Ok(GenResult {
content: output,
resolutions,
})
}
fn is_json_template(content: &str) -> bool {
content.contains("{{lkr:")
}
fn escape_json_value(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
_ => out.push(c),
}
}
out
}
fn write_secure(path: &Path, content: &str) -> Result<()> {
let parent = path.parent().unwrap_or(Path::new("."));
let tmp_path = parent.join(format!(".lkr-gen-{}.tmp", std::process::id()));
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| Error::Template(format!("Cannot write to '{}': {}", tmp_path.display(), e)))?;
file.write_all(content.as_bytes())
.map_err(|e| Error::Template(format!("Write failed: {}", e)))?;
file.flush()
.map_err(|e| Error::Template(format!("Flush failed: {}", e)))?;
fs::rename(&tmp_path, path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
Error::Template(format!("Cannot rename to '{}': {}", path.display(), e))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keymanager::{KeyKind, MockStore};
fn setup_store() -> MockStore {
let store = MockStore::new();
store
.set(
"openai:prod",
"sk-test-openai-key-12345678",
KeyKind::Runtime,
false,
)
.unwrap();
store
.set(
"anthropic:main",
"sk-ant-test-key-87654321",
KeyKind::Runtime,
false,
)
.unwrap();
store
}
#[test]
fn test_env_resolves_known_providers() {
let store = setup_store();
let template = "\
# My config
OPENAI_API_KEY=your-key-here
ANTHROPIC_API_KEY=change-me
DATABASE_URL=postgres://localhost/mydb
";
let result = generate_env(&store, template).unwrap();
assert!(
result
.content
.contains("OPENAI_API_KEY=sk-test-openai-key-12345678")
);
assert!(
result
.content
.contains("ANTHROPIC_API_KEY=sk-ant-test-key-87654321")
);
assert!(
result
.content
.contains("DATABASE_URL=postgres://localhost/mydb")
);
assert!(result.content.contains("# My config"));
assert_eq!(result.resolutions.len(), 3);
assert!(result.resolutions[0].key_name.is_some());
assert!(result.resolutions[1].key_name.is_some());
assert!(result.resolutions[2].key_name.is_none());
}
#[test]
fn test_env_preserves_comments_and_blanks() {
let store = setup_store();
let template = "# Comment\n\n# Another\nFOO=bar\n";
let result = generate_env(&store, template).unwrap();
assert_eq!(result.content, "# Comment\n\n# Another\nFOO=bar\n");
}
#[test]
fn test_env_unresolved_kept_as_is() {
let store = setup_store();
let template = "UNKNOWN_KEY=placeholder\n";
let result = generate_env(&store, template).unwrap();
assert_eq!(result.content, "UNKNOWN_KEY=placeholder\n");
assert!(result.resolutions[0].key_name.is_none());
}
#[test]
fn test_env_does_not_match_prefix_only() {
let store = MockStore::new();
store
.set("aws:prod", "AKIAIOSFODNN7EXAMPLE", KeyKind::Runtime, false)
.unwrap();
let template = "\
AWS_REGION=us-east-1
AWS_API_KEY=your-key-here
AWS_DEFAULT_REGION=ap-northeast-1
";
let result = generate_env(&store, template).unwrap();
assert!(result.content.contains("AWS_REGION=us-east-1"));
assert!(result.content.contains("AWS_DEFAULT_REGION=ap-northeast-1"));
assert!(result.content.contains("AWS_API_KEY=AKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn test_json_resolves_placeholders() {
let store = setup_store();
let template = r#"{
"mcpServers": {
"codex": {
"env": {
"OPENAI_API_KEY": "{{lkr:openai:prod}}"
}
}
}
}"#;
let result = generate_json(&store, template).unwrap();
assert!(
result
.content
.contains("\"OPENAI_API_KEY\": \"sk-test-openai-key-12345678\"")
);
assert!(!result.content.contains("{{lkr:"));
assert_eq!(result.resolutions.len(), 1);
assert_eq!(
result.resolutions[0].key_name.as_deref(),
Some("openai:prod")
);
}
#[test]
fn test_json_multiple_placeholders() {
let store = setup_store();
let template = r#"{"a": "{{lkr:openai:prod}}", "b": "{{lkr:anthropic:main}}"}"#;
let result = generate_json(&store, template).unwrap();
assert!(result.content.contains("sk-test-openai-key-12345678"));
assert!(result.content.contains("sk-ant-test-key-87654321"));
assert_eq!(result.resolutions.len(), 2);
}
#[test]
fn test_json_unresolved_placeholder_kept() {
let store = setup_store();
let template = r#"{"key": "{{lkr:unknown:key}}"}"#;
let result = generate_json(&store, template).unwrap();
assert!(result.content.contains("{{lkr:unknown:key}}"));
assert!(result.resolutions[0].key_name.is_none());
}
#[test]
fn test_json_unclosed_placeholder_error() {
let store = setup_store();
let template = r#"{"key": "{{lkr:openai:prod"}"#;
let err = generate_json(&store, template).unwrap_err();
assert!(matches!(err, Error::Template(_)));
}
#[test]
fn test_json_admin_key_rejected() {
let store = MockStore::new();
store
.set("openai:admin", "sk-admin-secret", KeyKind::Admin, false)
.unwrap();
let template = r#"{"key": "{{lkr:openai:admin}}"}"#;
let err = generate_json(&store, template).unwrap_err();
assert!(matches!(err, Error::Template(_)));
}
#[test]
fn test_json_escapes_special_chars_in_value() {
let store = MockStore::new();
store
.set(
"test:special",
r#"key-with-"quotes"-and-\backslash"#,
KeyKind::Runtime,
false,
)
.unwrap();
let template = r#"{"key": "{{lkr:test:special}}"}"#;
let result = generate_json(&store, template).unwrap();
assert!(
result
.content
.contains(r#"key-with-\"quotes\"-and-\\backslash"#)
);
assert!(result.resolutions[0].key_name.is_some());
}
#[test]
fn test_is_json_template() {
assert!(is_json_template(r#"{"key": "{{lkr:openai:prod}}"}"#));
assert!(!is_json_template("OPENAI_API_KEY=value"));
}
#[test]
fn test_key_to_env_var_known_providers() {
assert_eq!(key_to_env_var("openai:prod"), "OPENAI_API_KEY");
assert_eq!(key_to_env_var("anthropic:main"), "ANTHROPIC_API_KEY");
assert_eq!(key_to_env_var("google:dev"), "GOOGLE_API_KEY");
assert_eq!(key_to_env_var("deepseek:api"), "DEEPSEEK_API_KEY");
assert_eq!(key_to_env_var("xai:prod"), "XAI_API_KEY");
}
#[test]
fn test_key_to_env_var_unknown_provider() {
assert_eq!(key_to_env_var("custom:dev"), "CUSTOM_DEV");
assert_eq!(key_to_env_var("my-service:prod"), "MY-SERVICE_PROD");
}
#[test]
fn test_key_to_env_var_label_independence() {
assert_eq!(
key_to_env_var("openai:prod"),
key_to_env_var("openai:staging")
);
assert_eq!(
key_to_env_var("anthropic:main"),
key_to_env_var("anthropic:test")
);
}
#[test]
fn test_write_secure_permissions() {
let dir = std::env::temp_dir().join("lkr-test-write");
let _ = fs::create_dir_all(&dir);
let path = dir.join("test-output.env");
write_secure(&path, "SECRET=value\n").unwrap();
let metadata = fs::metadata(&path).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir(&dir);
}
}