1use serde::{Deserialize, Serialize};
8
9use crate::context::ProcessContext;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SecretDef {
17 pub key: String,
19
20 #[serde(default, skip_serializing_if = "String::is_empty")]
22 pub description: String,
23
24 #[serde(default = "default_required")]
27 pub required: bool,
28}
29
30fn default_required() -> bool {
31 true
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SecretStatus {
37 pub key: String,
38 pub description: String,
39 pub required: bool,
40 pub present: bool,
41}
42
43pub fn check_secrets(secrets: &[SecretDef], ctx: &dyn ProcessContext) -> Vec<SecretStatus> {
48 secrets
49 .iter()
50 .map(|s| SecretStatus {
51 key: s.key.clone(),
52 description: s.description.clone(),
53 required: s.required,
54 present: ctx.env_var(&s.key).is_some(),
55 })
56 .collect()
57}
58
59pub fn missing_required(statuses: &[SecretStatus]) -> Vec<&SecretStatus> {
61 statuses
62 .iter()
63 .filter(|s| s.required && !s.present)
64 .collect()
65}
66
67pub fn format_missing_error(missing: &[&SecretStatus]) -> String {
69 let mut lines = vec!["Missing required secrets:".to_string()];
70 for s in missing {
71 if s.description.is_empty() {
72 lines.push(format!(" - {}", s.key));
73 } else {
74 lines.push(format!(" - {} ({})", s.key, s.description));
75 }
76 }
77 lines.push(String::new());
78 lines.push("Set them via environment variables or add to ~/.config/bnto/.env".to_string());
79 lines.join("\n")
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use crate::context::NoopContext;
86 use std::collections::BTreeMap;
87 use std::path::{Path, PathBuf};
88
89 struct MockCtx {
91 env: BTreeMap<String, String>,
92 }
93
94 impl MockCtx {
95 fn new(pairs: &[(&str, &str)]) -> Self {
96 Self {
97 env: pairs
98 .iter()
99 .map(|(k, v)| (k.to_string(), v.to_string()))
100 .collect(),
101 }
102 }
103 }
104
105 impl ProcessContext for MockCtx {
106 fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, crate::BntoError> {
107 Err(crate::BntoError::ProcessingFailed("mock".into()))
108 }
109 fn temp_file(&self, _suffix: &str) -> Result<PathBuf, crate::BntoError> {
110 Ok(PathBuf::from("/tmp/mock"))
111 }
112 fn env_var(&self, key: &str) -> Option<String> {
113 self.env.get(key).cloned()
114 }
115 fn work_dir(&self) -> Result<&Path, crate::BntoError> {
116 Err(crate::BntoError::ProcessingFailed("mock".into()))
117 }
118 }
119
120 fn secret(key: &str, required: bool) -> SecretDef {
121 SecretDef {
122 key: key.to_string(),
123 description: String::new(),
124 required,
125 }
126 }
127
128 fn secret_with_desc(key: &str, desc: &str, required: bool) -> SecretDef {
129 SecretDef {
130 key: key.to_string(),
131 description: desc.to_string(),
132 required,
133 }
134 }
135
136 #[test]
137 fn all_required_present() {
138 let secrets = vec![secret("API_KEY", true), secret("DB_URL", true)];
139 let ctx = MockCtx::new(&[("API_KEY", "sk-123"), ("DB_URL", "postgres://")]);
140 let statuses = check_secrets(&secrets, &ctx);
141 let missing = missing_required(&statuses);
142 assert!(missing.is_empty());
143 }
144
145 #[test]
146 fn required_secret_missing() {
147 let secrets = vec![secret("API_KEY", true)];
148 let ctx = MockCtx::new(&[]);
149 let statuses = check_secrets(&secrets, &ctx);
150 let missing = missing_required(&statuses);
151 assert_eq!(missing.len(), 1);
152 assert_eq!(missing[0].key, "API_KEY");
153 }
154
155 #[test]
156 fn optional_secret_missing_is_ok() {
157 let secrets = vec![secret("OPTIONAL_KEY", false)];
158 let ctx = MockCtx::new(&[]);
159 let statuses = check_secrets(&secrets, &ctx);
160 let missing = missing_required(&statuses);
161 assert!(missing.is_empty());
162 }
163
164 #[test]
165 fn mixed_required_and_optional() {
166 let secrets = vec![
167 secret("REQUIRED", true),
168 secret("OPTIONAL", false),
169 secret("ALSO_REQUIRED", true),
170 ];
171 let ctx = MockCtx::new(&[("REQUIRED", "yes")]);
172 let statuses = check_secrets(&secrets, &ctx);
173 let missing = missing_required(&statuses);
174 assert_eq!(missing.len(), 1);
175 assert_eq!(missing[0].key, "ALSO_REQUIRED");
176 }
177
178 #[test]
179 fn empty_secrets_list() {
180 let ctx = MockCtx::new(&[]);
181 let statuses = check_secrets(&[], &ctx);
182 assert!(statuses.is_empty());
183 }
184
185 #[test]
186 fn noop_context_returns_all_missing() {
187 let secrets = vec![secret("KEY", true)];
188 let statuses = check_secrets(&secrets, &NoopContext);
189 assert!(!statuses[0].present);
190 }
191
192 #[test]
193 fn format_error_with_descriptions() {
194 let statuses = [
195 SecretStatus {
196 key: "API_KEY".into(),
197 description: "OpenAI API key".into(),
198 required: true,
199 present: false,
200 },
201 SecretStatus {
202 key: "DB_URL".into(),
203 description: String::new(),
204 required: true,
205 present: false,
206 },
207 ];
208 let missing: Vec<&SecretStatus> = statuses.iter().collect();
209 let msg = format_missing_error(&missing);
210 assert!(msg.contains("API_KEY (OpenAI API key)"));
211 assert!(msg.contains(" - DB_URL"));
212 assert!(!msg.contains("DB_URL ("));
213 assert!(msg.contains("~/.config/bnto/.env"));
214 }
215
216 #[test]
217 fn secret_def_deserializes_with_defaults() {
218 let json = r#"{ "key": "API_KEY" }"#;
219 let s: SecretDef = serde_json::from_str(json).unwrap();
220 assert_eq!(s.key, "API_KEY");
221 assert!(s.required);
222 assert!(s.description.is_empty());
223 }
224
225 #[test]
226 fn secret_def_deserializes_full() {
227 let json = r#"{
228 "key": "OPENAI_API_KEY",
229 "description": "OpenAI API key for GPT-4",
230 "required": false
231 }"#;
232 let s: SecretDef = serde_json::from_str(json).unwrap();
233 assert_eq!(s.key, "OPENAI_API_KEY");
234 assert_eq!(s.description, "OpenAI API key for GPT-4");
235 assert!(!s.required);
236 }
237
238 #[test]
239 fn secret_def_round_trip() {
240 let original = secret_with_desc("KEY", "desc", true);
241 let json = serde_json::to_string(&original).unwrap();
242 let parsed: SecretDef = serde_json::from_str(&json).unwrap();
243 assert_eq!(original, parsed);
244 }
245
246 #[test]
247 fn empty_description_omitted_in_serialization() {
248 let s = secret("KEY", true);
249 let json = serde_json::to_string(&s).unwrap();
250 assert!(!json.contains("description"));
251 }
252}