use serde::{Deserialize, Serialize};
use crate::context::ProcessContext;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SecretDef {
pub key: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default = "default_required")]
pub required: bool,
}
fn default_required() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretStatus {
pub key: String,
pub description: String,
pub required: bool,
pub present: bool,
}
pub fn check_secrets(secrets: &[SecretDef], ctx: &dyn ProcessContext) -> Vec<SecretStatus> {
secrets
.iter()
.map(|s| SecretStatus {
key: s.key.clone(),
description: s.description.clone(),
required: s.required,
present: ctx.env_var(&s.key).is_some(),
})
.collect()
}
pub fn missing_required(statuses: &[SecretStatus]) -> Vec<&SecretStatus> {
statuses
.iter()
.filter(|s| s.required && !s.present)
.collect()
}
pub fn format_missing_error(missing: &[&SecretStatus]) -> String {
let mut lines = vec!["Missing required secrets:".to_string()];
for s in missing {
if s.description.is_empty() {
lines.push(format!(" - {}", s.key));
} else {
lines.push(format!(" - {} ({})", s.key, s.description));
}
}
lines.push(String::new());
lines.push("Set them via environment variables or add to ~/.config/bnto/.env".to_string());
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::NoopContext;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
struct MockCtx {
env: BTreeMap<String, String>,
}
impl MockCtx {
fn new(pairs: &[(&str, &str)]) -> Self {
Self {
env: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
}
impl ProcessContext for MockCtx {
fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, crate::BntoError> {
Err(crate::BntoError::ProcessingFailed("mock".into()))
}
fn temp_file(&self, _suffix: &str) -> Result<PathBuf, crate::BntoError> {
Ok(PathBuf::from("/tmp/mock"))
}
fn env_var(&self, key: &str) -> Option<String> {
self.env.get(key).cloned()
}
fn work_dir(&self) -> Result<&Path, crate::BntoError> {
Err(crate::BntoError::ProcessingFailed("mock".into()))
}
}
fn secret(key: &str, required: bool) -> SecretDef {
SecretDef {
key: key.to_string(),
description: String::new(),
required,
}
}
fn secret_with_desc(key: &str, desc: &str, required: bool) -> SecretDef {
SecretDef {
key: key.to_string(),
description: desc.to_string(),
required,
}
}
#[test]
fn all_required_present() {
let secrets = vec![secret("API_KEY", true), secret("DB_URL", true)];
let ctx = MockCtx::new(&[("API_KEY", "sk-123"), ("DB_URL", "postgres://")]);
let statuses = check_secrets(&secrets, &ctx);
let missing = missing_required(&statuses);
assert!(missing.is_empty());
}
#[test]
fn required_secret_missing() {
let secrets = vec![secret("API_KEY", true)];
let ctx = MockCtx::new(&[]);
let statuses = check_secrets(&secrets, &ctx);
let missing = missing_required(&statuses);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].key, "API_KEY");
}
#[test]
fn optional_secret_missing_is_ok() {
let secrets = vec![secret("OPTIONAL_KEY", false)];
let ctx = MockCtx::new(&[]);
let statuses = check_secrets(&secrets, &ctx);
let missing = missing_required(&statuses);
assert!(missing.is_empty());
}
#[test]
fn mixed_required_and_optional() {
let secrets = vec![
secret("REQUIRED", true),
secret("OPTIONAL", false),
secret("ALSO_REQUIRED", true),
];
let ctx = MockCtx::new(&[("REQUIRED", "yes")]);
let statuses = check_secrets(&secrets, &ctx);
let missing = missing_required(&statuses);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].key, "ALSO_REQUIRED");
}
#[test]
fn empty_secrets_list() {
let ctx = MockCtx::new(&[]);
let statuses = check_secrets(&[], &ctx);
assert!(statuses.is_empty());
}
#[test]
fn noop_context_returns_all_missing() {
let secrets = vec![secret("KEY", true)];
let statuses = check_secrets(&secrets, &NoopContext);
assert!(!statuses[0].present);
}
#[test]
fn format_error_with_descriptions() {
let statuses = [
SecretStatus {
key: "API_KEY".into(),
description: "OpenAI API key".into(),
required: true,
present: false,
},
SecretStatus {
key: "DB_URL".into(),
description: String::new(),
required: true,
present: false,
},
];
let missing: Vec<&SecretStatus> = statuses.iter().collect();
let msg = format_missing_error(&missing);
assert!(msg.contains("API_KEY (OpenAI API key)"));
assert!(msg.contains(" - DB_URL"));
assert!(!msg.contains("DB_URL ("));
assert!(msg.contains("~/.config/bnto/.env"));
}
#[test]
fn secret_def_deserializes_with_defaults() {
let json = r#"{ "key": "API_KEY" }"#;
let s: SecretDef = serde_json::from_str(json).unwrap();
assert_eq!(s.key, "API_KEY");
assert!(s.required);
assert!(s.description.is_empty());
}
#[test]
fn secret_def_deserializes_full() {
let json = r#"{
"key": "OPENAI_API_KEY",
"description": "OpenAI API key for GPT-4",
"required": false
}"#;
let s: SecretDef = serde_json::from_str(json).unwrap();
assert_eq!(s.key, "OPENAI_API_KEY");
assert_eq!(s.description, "OpenAI API key for GPT-4");
assert!(!s.required);
}
#[test]
fn secret_def_round_trip() {
let original = secret_with_desc("KEY", "desc", true);
let json = serde_json::to_string(&original).unwrap();
let parsed: SecretDef = serde_json::from_str(&json).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn empty_description_omitted_in_serialization() {
let s = secret("KEY", true);
let json = serde_json::to_string(&s).unwrap();
assert!(!json.contains("description"));
}
}