1use std::collections::HashMap;
22
23#[derive(Clone)]
46pub struct DataverseSourceConfig {
47 pub environment_url: String,
49
50 pub tenant_id: String,
53
54 pub client_id: String,
57
58 pub client_secret: String,
61
62 pub use_azure_cli: bool,
66
67 pub entities: Vec<String>,
71
72 pub entity_set_overrides: HashMap<String, String>,
78
79 pub entity_columns: HashMap<String, Vec<String>>,
82
83 pub min_interval_ms: u64,
86
87 pub max_interval_seconds: u64,
92
93 pub api_version: String,
95}
96
97impl std::fmt::Debug for DataverseSourceConfig {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct("DataverseSourceConfig")
102 .field("environment_url", &self.environment_url)
103 .field("tenant_id", &self.tenant_id)
104 .field("client_id", &self.client_id)
105 .field("client_secret", &"[REDACTED]")
106 .field("use_azure_cli", &self.use_azure_cli)
107 .field("entities", &self.entities)
108 .field("entity_set_overrides", &self.entity_set_overrides)
109 .field("entity_columns", &self.entity_columns)
110 .field("min_interval_ms", &self.min_interval_ms)
111 .field("max_interval_seconds", &self.max_interval_seconds)
112 .field("api_version", &self.api_version)
113 .finish()
114 }
115}
116
117impl DataverseSourceConfig {
118 pub fn validate(&self) -> Result<(), String> {
120 if self.environment_url.is_empty() {
121 return Err("environment_url is required".to_string());
122 }
123 if !self.use_azure_cli {
124 if self.tenant_id.is_empty() {
126 return Err("tenant_id is required (or set use_azure_cli = true)".to_string());
127 }
128 if self.client_id.is_empty() {
129 return Err("client_id is required (or set use_azure_cli = true)".to_string());
130 }
131 if self.client_secret.is_empty() {
132 return Err("client_secret is required (or set use_azure_cli = true)".to_string());
133 }
134 }
135 if self.entities.is_empty() {
136 return Err("entities list must not be empty".to_string());
137 }
138 Ok(())
139 }
140
141 pub fn validate_with_identity_provider(&self) -> Result<(), String> {
147 if self.environment_url.is_empty() {
148 return Err("environment_url is required".to_string());
149 }
150 if self.entities.is_empty() {
151 return Err("entities list must not be empty".to_string());
152 }
153 Ok(())
154 }
155
156 pub fn entity_set_name(&self, entity: &str) -> String {
162 if let Some(override_name) = self.entity_set_overrides.get(entity) {
163 override_name.clone()
164 } else {
165 format!("{entity}s")
166 }
167 }
168
169 pub fn select_columns(&self, entity: &str) -> Option<String> {
175 self.entity_columns.get(entity).map(|cols| {
176 let primary_key = format!("{entity}id");
177 let has_primary_key = cols.iter().any(|c| c.eq_ignore_ascii_case(&primary_key));
178 if has_primary_key {
179 cols.join(",")
180 } else {
181 let mut all_columns = cols.clone();
182 all_columns.push(primary_key);
183 all_columns.join(",")
184 }
185 })
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn test_config_validation_success() {
195 let config = DataverseSourceConfig {
196 environment_url: "https://myorg.crm.dynamics.com".to_string(),
197 tenant_id: "tenant-1".to_string(),
198 client_id: "client-1".to_string(),
199 client_secret: "secret-1".to_string(),
200 use_azure_cli: false,
201 entities: vec!["account".to_string()],
202 entity_set_overrides: HashMap::new(),
203 entity_columns: HashMap::new(),
204 min_interval_ms: 500,
205 max_interval_seconds: 30,
206 api_version: "v9.2".to_string(),
207 };
208 assert!(config.validate().is_ok());
209 }
210
211 #[test]
212 fn test_config_validation_azure_cli_no_secret_needed() {
213 let config = DataverseSourceConfig {
214 environment_url: "https://myorg.crm.dynamics.com".to_string(),
215 tenant_id: String::new(),
216 client_id: String::new(),
217 client_secret: String::new(),
218 use_azure_cli: true,
219 entities: vec!["account".to_string()],
220 entity_set_overrides: HashMap::new(),
221 entity_columns: HashMap::new(),
222 min_interval_ms: 500,
223 max_interval_seconds: 30,
224 api_version: "v9.2".to_string(),
225 };
226 assert!(config.validate().is_ok());
227 }
228
229 #[test]
230 fn test_config_validation_empty_url() {
231 let config = DataverseSourceConfig {
232 environment_url: String::new(),
233 tenant_id: "t".to_string(),
234 client_id: "c".to_string(),
235 client_secret: "s".to_string(),
236 use_azure_cli: false,
237 entities: vec!["account".to_string()],
238 entity_set_overrides: HashMap::new(),
239 entity_columns: HashMap::new(),
240 min_interval_ms: 500,
241 max_interval_seconds: 30,
242 api_version: "v9.2".to_string(),
243 };
244 assert!(config.validate().is_err());
245 }
246
247 #[test]
248 fn test_config_validation_empty_entities() {
249 let config = DataverseSourceConfig {
250 environment_url: "https://test.crm.dynamics.com".to_string(),
251 tenant_id: "t".to_string(),
252 client_id: "c".to_string(),
253 client_secret: "s".to_string(),
254 use_azure_cli: false,
255 entities: vec![],
256 entity_set_overrides: HashMap::new(),
257 entity_columns: HashMap::new(),
258 min_interval_ms: 500,
259 max_interval_seconds: 30,
260 api_version: "v9.2".to_string(),
261 };
262 assert!(config.validate().is_err());
263 }
264
265 #[test]
266 fn test_entity_set_name_default() {
267 let config = DataverseSourceConfig {
268 environment_url: "https://test.crm.dynamics.com".to_string(),
269 tenant_id: "t".to_string(),
270 client_id: "c".to_string(),
271 client_secret: "s".to_string(),
272 use_azure_cli: false,
273 entities: vec!["account".to_string()],
274 entity_set_overrides: HashMap::new(),
275 entity_columns: HashMap::new(),
276 min_interval_ms: 500,
277 max_interval_seconds: 30,
278 api_version: "v9.2".to_string(),
279 };
280 assert_eq!(config.entity_set_name("account"), "accounts");
281 assert_eq!(config.entity_set_name("contact"), "contacts");
282 }
283
284 #[test]
285 fn test_entity_set_name_override() {
286 let mut overrides = HashMap::new();
287 overrides.insert("activityparty".to_string(), "activityparties".to_string());
288 let config = DataverseSourceConfig {
289 environment_url: "https://test.crm.dynamics.com".to_string(),
290 tenant_id: "t".to_string(),
291 client_id: "c".to_string(),
292 client_secret: "s".to_string(),
293 use_azure_cli: false,
294 entities: vec!["activityparty".to_string()],
295 entity_set_overrides: overrides,
296 entity_columns: HashMap::new(),
297 min_interval_ms: 500,
298 max_interval_seconds: 30,
299 api_version: "v9.2".to_string(),
300 };
301 assert_eq!(config.entity_set_name("activityparty"), "activityparties");
302 }
303
304 #[test]
305 fn test_select_columns() {
306 let mut cols = HashMap::new();
307 cols.insert(
308 "account".to_string(),
309 vec!["name".to_string(), "revenue".to_string()],
310 );
311 let config = DataverseSourceConfig {
312 environment_url: "https://test.crm.dynamics.com".to_string(),
313 tenant_id: "t".to_string(),
314 client_id: "c".to_string(),
315 client_secret: "s".to_string(),
316 use_azure_cli: false,
317 entities: vec!["account".to_string()],
318 entity_set_overrides: HashMap::new(),
319 entity_columns: cols,
320 min_interval_ms: 500,
321 max_interval_seconds: 30,
322 api_version: "v9.2".to_string(),
323 };
324 assert_eq!(
326 config.select_columns("account"),
327 Some("name,revenue,accountid".to_string())
328 );
329 assert_eq!(config.select_columns("contact"), None);
330 }
331
332 #[test]
333 fn test_select_columns_appends_primary_key() {
334 let mut entity_columns = HashMap::new();
335 entity_columns.insert("account".to_string(), vec!["name".to_string()]);
336 let config = DataverseSourceConfig {
337 environment_url: "https://myorg.crm.dynamics.com".to_string(),
338 tenant_id: "t".to_string(),
339 client_id: "c".to_string(),
340 client_secret: "s".to_string(),
341 use_azure_cli: false,
342 entities: vec!["account".to_string()],
343 entity_set_overrides: HashMap::new(),
344 entity_columns,
345 min_interval_ms: 500,
346 max_interval_seconds: 30,
347 api_version: "v9.2".to_string(),
348 };
349 assert_eq!(
350 config.select_columns("account"),
351 Some("name,accountid".to_string())
352 );
353 }
354
355 #[test]
356 fn test_select_columns_does_not_duplicate_primary_key() {
357 let mut entity_columns = HashMap::new();
358 entity_columns.insert(
359 "account".to_string(),
360 vec!["name".to_string(), "accountid".to_string()],
361 );
362 let config = DataverseSourceConfig {
363 environment_url: "https://myorg.crm.dynamics.com".to_string(),
364 tenant_id: "t".to_string(),
365 client_id: "c".to_string(),
366 client_secret: "s".to_string(),
367 use_azure_cli: false,
368 entities: vec!["account".to_string()],
369 entity_set_overrides: HashMap::new(),
370 entity_columns,
371 min_interval_ms: 500,
372 max_interval_seconds: 30,
373 api_version: "v9.2".to_string(),
374 };
375 assert_eq!(
376 config.select_columns("account"),
377 Some("name,accountid".to_string())
378 );
379 }
380
381 #[test]
382 fn debug_redacts_client_secret() {
383 let config = DataverseSourceConfig {
386 environment_url: "https://myorg.crm.dynamics.com".to_string(),
387 tenant_id: "tenant-1".to_string(),
388 client_id: "client-1".to_string(),
389 client_secret: "super-secret-do-not-leak".to_string(),
390 use_azure_cli: false,
391 entities: vec!["account".to_string()],
392 entity_set_overrides: HashMap::new(),
393 entity_columns: HashMap::new(),
394 min_interval_ms: 500,
395 max_interval_seconds: 30,
396 api_version: "v9.2".to_string(),
397 };
398 let dbg = format!("{config:?}");
399 assert!(
400 !dbg.contains("super-secret-do-not-leak"),
401 "client_secret must not appear in Debug output: {dbg}"
402 );
403 assert!(dbg.contains("[REDACTED]"));
404 assert!(dbg.contains("tenant-1"));
406 assert!(dbg.contains("client-1"));
407 }
408}