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