1use super::manifest::ExtensionConfigEntry;
8
9pub fn redact_secret_value(value: &str) -> String {
11 let len = value.chars().count();
12 if len == 0 {
13 return String::new();
14 }
15 if len <= 3 {
16 return "***".to_string();
17 }
18 let tail_len = if len <= 7 { 2 } else { 4 };
19 let tail: String = value.chars().skip(len - tail_len).collect();
20 format!("***{}", tail)
21}
22
23pub fn extension_env_var(extension_id: &str, key: &str) -> String {
25 let id_upper = extension_id.replace('-', "_").to_ascii_uppercase();
26 let key_upper = key.replace('-', "_").to_ascii_uppercase();
27 format!("SYNAPS_EXTENSION_{}_{}", id_upper, key_upper)
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ConfigSource {
33 EnvOverride(String),
35 SecretEnv(String),
37 PluginConfig,
39 LegacyConfigKey(String),
41 Default,
43 Missing,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ConfigEntryStatus {
50 pub key: String,
51 pub description: Option<String>,
52 pub required: bool,
53 pub source: ConfigSource,
54 pub has_value: bool,
55}
56
57pub fn classify_config_entry(
61 extension_id: &str,
62 entry: &ExtensionConfigEntry,
63 env_lookup: &impl Fn(&str) -> Option<String>,
64 plugin_config_lookup: &impl Fn(&str) -> Option<String>,
65 legacy_config_lookup: &impl Fn(&str) -> Option<String>,
66) -> ConfigEntryStatus {
67 let env_var = extension_env_var(extension_id, &entry.key);
68 let legacy_config_key = format!("extension.{}.{}", extension_id, entry.key);
69
70 let source = if env_lookup(&env_var).is_some() {
71 ConfigSource::EnvOverride(env_var)
72 } else if let Some(secret_env) = entry.secret_env.as_ref() {
73 if env_lookup(secret_env).is_some() {
74 ConfigSource::SecretEnv(secret_env.clone())
75 } else if plugin_config_lookup(&entry.key).is_some() {
76 ConfigSource::PluginConfig
77 } else if legacy_config_lookup(&legacy_config_key).is_some() {
78 ConfigSource::LegacyConfigKey(legacy_config_key)
79 } else if entry.default.is_some() {
80 ConfigSource::Default
81 } else {
82 ConfigSource::Missing
83 }
84 } else if plugin_config_lookup(&entry.key).is_some() {
85 ConfigSource::PluginConfig
86 } else if legacy_config_lookup(&legacy_config_key).is_some() {
87 ConfigSource::LegacyConfigKey(legacy_config_key)
88 } else if entry.default.is_some() {
89 ConfigSource::Default
90 } else {
91 ConfigSource::Missing
92 };
93
94 let has_value = !matches!(source, ConfigSource::Missing);
95
96 ConfigEntryStatus {
97 key: entry.key.clone(),
98 description: entry.description.clone(),
99 required: entry.required,
100 source,
101 has_value,
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtensionConfigDiagnostics {
108 pub extension_id: String,
109 pub entries: Vec<ConfigEntryStatus>,
110 pub provider_missing: Vec<(String, String)>,
113}
114
115pub fn diagnose_extension_config(
119 extension_id: &str,
120 manifest_config: &[ExtensionConfigEntry],
121 provider_required: &[(String, Vec<String>)],
122 env_lookup: &impl Fn(&str) -> Option<String>,
123 plugin_config_lookup: &impl Fn(&str) -> Option<String>,
124 legacy_config_lookup: &impl Fn(&str) -> Option<String>,
125) -> ExtensionConfigDiagnostics {
126 let entries: Vec<ConfigEntryStatus> = manifest_config
127 .iter()
128 .map(|entry| {
129 classify_config_entry(
130 extension_id,
131 entry,
132 env_lookup,
133 plugin_config_lookup,
134 legacy_config_lookup,
135 )
136 })
137 .collect();
138
139 let mut provider_missing: Vec<(String, String)> = Vec::new();
140 for (provider_id, required_keys) in provider_required {
141 for key in required_keys {
142 let satisfied = entries
143 .iter()
144 .any(|status| status.key == *key && status.has_value);
145 if !satisfied {
146 provider_missing.push((provider_id.clone(), key.clone()));
147 }
148 }
149 }
150
151 ExtensionConfigDiagnostics {
152 extension_id: extension_id.to_string(),
153 entries,
154 provider_missing,
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use serde_json::Value;
162
163 fn empty_lookup(_: &str) -> Option<String> {
164 None
165 }
166
167 fn entry(key: &str) -> ExtensionConfigEntry {
168 ExtensionConfigEntry {
169 key: key.to_string(),
170 value_type: None,
171 description: None,
172 required: false,
173 default: None,
174 secret_env: None,
175 }
176 }
177
178 #[test]
179 fn redact_empty() {
180 assert_eq!(redact_secret_value(""), "");
181 }
182
183 #[test]
184 fn redact_short() {
185 assert_eq!(redact_secret_value("a"), "***");
186 assert_eq!(redact_secret_value("abc"), "***");
187 }
188
189 #[test]
190 fn redact_medium() {
191 assert_eq!(redact_secret_value("abcd"), "***cd");
192 assert_eq!(redact_secret_value("abc1234"), "***34");
193 }
194
195 #[test]
196 fn redact_long() {
197 assert_eq!(redact_secret_value("abc12345"), "***2345");
198 assert_eq!(
199 redact_secret_value("abcdefghijklmnopqrst"),
200 "***qrst"
201 );
202 let s = redact_secret_value("supersecretvalue1234");
204 assert!(s.starts_with("***"));
205 assert!(!s.contains("supersecret"));
206 }
207
208 #[test]
209 fn env_var_uppercases_and_replaces_dashes() {
210 assert_eq!(
211 extension_env_var("my-ext", "api-key"),
212 "SYNAPS_EXTENSION_MY_EXT_API_KEY"
213 );
214 }
215
216 #[test]
217 fn classify_env_override() {
218 let e = entry("api-key");
219 let env = |k: &str| {
220 if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
221 Some("v".to_string())
222 } else {
223 None
224 }
225 };
226 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
227 assert_eq!(
228 status.source,
229 ConfigSource::EnvOverride("SYNAPS_EXTENSION_MY_EXT_API_KEY".to_string())
230 );
231 assert!(status.has_value);
232 }
233
234 #[test]
235 fn classify_secret_env() {
236 let mut e = entry("api-key");
237 e.secret_env = Some("MY_PROVIDER_KEY".to_string());
238 let env = |k: &str| {
239 if k == "MY_PROVIDER_KEY" {
240 Some("v".to_string())
241 } else {
242 None
243 }
244 };
245 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
246 assert_eq!(
247 status.source,
248 ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
249 );
250 assert!(status.has_value);
251 }
252
253 #[test]
254 fn classify_plugin_config() {
255 let e = entry("api-key");
256 let plugin = |k: &str| {
257 if k == "api-key" { Some("v".to_string()) } else { None }
258 };
259 let status = classify_config_entry("my-ext", &e, &empty_lookup, &plugin, &empty_lookup);
260 assert_eq!(status.source, ConfigSource::PluginConfig);
261 assert!(status.has_value);
262 }
263
264 #[test]
265 fn classify_config_key() {
266 let e = entry("api-key");
267 let cfg = |k: &str| {
268 if k == "extension.my-ext.api-key" {
269 Some("v".to_string())
270 } else {
271 None
272 }
273 };
274 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
275 assert_eq!(
276 status.source,
277 ConfigSource::LegacyConfigKey("extension.my-ext.api-key".to_string())
278 );
279 assert!(status.has_value);
280 }
281
282 #[test]
283 fn classify_default() {
284 let mut e = entry("region");
285 e.default = Some(Value::String("us-east-1".to_string()));
286 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
287 assert_eq!(status.source, ConfigSource::Default);
288 assert!(status.has_value);
289 }
290
291 #[test]
292 fn classify_missing() {
293 let mut e = entry("api-key");
294 e.required = true;
295 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
296 assert_eq!(status.source, ConfigSource::Missing);
297 assert!(!status.has_value);
298 assert!(status.required);
299 }
300
301 #[test]
302 fn env_override_wins_over_all() {
303 let mut e = entry("api-key");
304 e.secret_env = Some("MY_PROVIDER_KEY".to_string());
305 e.default = Some(Value::String("d".to_string()));
306 let env = |k: &str| Some(format!("env-{}", k));
307 let cfg = |_: &str| Some("cfg".to_string());
308 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
309 assert!(matches!(status.source, ConfigSource::EnvOverride(_)));
310 }
311
312 #[test]
313 fn secret_env_wins_over_config_and_default() {
314 let mut e = entry("api-key");
315 e.secret_env = Some("MY_PROVIDER_KEY".to_string());
316 e.default = Some(Value::String("d".to_string()));
317 let env = |k: &str| {
318 if k == "MY_PROVIDER_KEY" {
319 Some("s".to_string())
320 } else {
321 None
322 }
323 };
324 let cfg = |_: &str| Some("cfg".to_string());
325 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
326 assert_eq!(
327 status.source,
328 ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
329 );
330 }
331
332 #[test]
333 fn config_key_wins_over_default() {
334 let mut e = entry("region");
335 e.default = Some(Value::String("us-east-1".to_string()));
336 let cfg = |k: &str| {
337 if k == "extension.my-ext.region" {
338 Some("eu-west-1".to_string())
339 } else {
340 None
341 }
342 };
343 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
344 assert!(matches!(status.source, ConfigSource::LegacyConfigKey(_)));
345 }
346
347 #[test]
348 fn default_only_when_no_env_or_config() {
349 let mut e = entry("region");
350 e.default = Some(Value::String("us-east-1".to_string()));
351 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
352 assert_eq!(status.source, ConfigSource::Default);
353 }
354
355 #[test]
356 fn diagnose_empty_manifest_no_providers() {
357 let diag = diagnose_extension_config(
358 "my-ext",
359 &[],
360 &[],
361 &empty_lookup,
362 &empty_lookup,
363 &empty_lookup,
364 );
365 assert_eq!(diag.extension_id, "my-ext");
366 assert!(diag.entries.is_empty());
367 assert!(diag.provider_missing.is_empty());
368 }
369
370 #[test]
371 fn diagnose_entry_with_default_resolves() {
372 let mut e = entry("region");
373 e.default = Some(Value::String("us-east-1".to_string()));
374 let diag = diagnose_extension_config(
375 "my-ext",
376 std::slice::from_ref(&e),
377 &[],
378 &empty_lookup,
379 &empty_lookup,
380 &empty_lookup,
381 );
382 assert_eq!(diag.entries.len(), 1);
383 assert_eq!(diag.entries[0].source, ConfigSource::Default);
384 assert!(diag.entries[0].has_value);
385 assert!(diag.provider_missing.is_empty());
386 }
387
388 #[test]
389 fn diagnose_provider_requires_undeclared_key() {
390 let diag = diagnose_extension_config(
391 "my-ext",
392 &[],
393 &[("p".to_string(), vec!["api-key".to_string()])],
394 &empty_lookup,
395 &empty_lookup,
396 &empty_lookup,
397 );
398 assert_eq!(
399 diag.provider_missing,
400 vec![("p".to_string(), "api-key".to_string())]
401 );
402 }
403
404 #[test]
405 fn diagnose_provider_required_key_resolved_via_env() {
406 let mut e = entry("api-key");
407 e.required = true;
408 let env = |k: &str| {
409 if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
410 Some("v".to_string())
411 } else {
412 None
413 }
414 };
415 let diag = diagnose_extension_config(
416 "my-ext",
417 std::slice::from_ref(&e),
418 &[("p".to_string(), vec!["api-key".to_string()])],
419 &env,
420 &empty_lookup,
421 &empty_lookup,
422 );
423 assert!(diag.entries[0].has_value);
424 assert!(diag.provider_missing.is_empty());
425 }
426
427 #[test]
428 fn diagnose_provider_required_key_declared_but_missing() {
429 let mut e = entry("api-key");
430 e.required = true;
431 let diag = diagnose_extension_config(
432 "my-ext",
433 std::slice::from_ref(&e),
434 &[("p".to_string(), vec!["api-key".to_string()])],
435 &empty_lookup,
436 &empty_lookup,
437 &empty_lookup,
438 );
439 assert!(!diag.entries[0].has_value);
440 assert_eq!(
441 diag.provider_missing,
442 vec![("p".to_string(), "api-key".to_string())]
443 );
444 }
445}