1use std::collections::HashMap;
22
23#[derive(Clone)]
45pub struct DataverseSourceConfig {
46 pub environment_url: String,
48
49 pub tenant_id: String,
53
54 pub client_id: String,
58
59 pub client_secret: String,
63
64 pub entities: Vec<String>,
68
69 pub entity_set_overrides: HashMap<String, String>,
75
76 pub entity_columns: HashMap<String, Vec<String>>,
79
80 pub min_interval_ms: u64,
83
84 pub max_interval_seconds: u64,
89
90 pub api_version: String,
92}
93
94impl std::fmt::Debug for DataverseSourceConfig {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.debug_struct("DataverseSourceConfig")
99 .field("environment_url", &self.environment_url)
100 .field("tenant_id", &self.tenant_id)
101 .field("client_id", &self.client_id)
102 .field("client_secret", &"[REDACTED]")
103 .field("entities", &self.entities)
104 .field("entity_set_overrides", &self.entity_set_overrides)
105 .field("entity_columns", &self.entity_columns)
106 .field("min_interval_ms", &self.min_interval_ms)
107 .field("max_interval_seconds", &self.max_interval_seconds)
108 .field("api_version", &self.api_version)
109 .finish()
110 }
111}
112
113impl DataverseSourceConfig {
114 pub fn validate(&self) -> Result<(), String> {
121 if self.environment_url.is_empty() {
122 return Err("environment_url is required".to_string());
123 }
124 if self.tenant_id.is_empty() {
125 return Err("tenant_id is required (or configure an identity provider)".to_string());
126 }
127 if self.client_id.is_empty() {
128 return Err("client_id is required (or configure an identity provider)".to_string());
129 }
130 if self.client_secret.is_empty() {
131 return Err(
132 "client_secret is required (or configure an identity provider)".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 entities: vec!["account".to_string()],
201 entity_set_overrides: HashMap::new(),
202 entity_columns: HashMap::new(),
203 min_interval_ms: 500,
204 max_interval_seconds: 30,
205 api_version: "v9.2".to_string(),
206 };
207 assert!(config.validate().is_ok());
208 }
209
210 #[test]
211 fn test_validate_with_identity_provider_skips_credentials() {
212 let config = DataverseSourceConfig {
213 environment_url: "https://myorg.crm.dynamics.com".to_string(),
214 tenant_id: String::new(),
215 client_id: String::new(),
216 client_secret: String::new(),
217 entities: vec!["account".to_string()],
218 entity_set_overrides: HashMap::new(),
219 entity_columns: HashMap::new(),
220 min_interval_ms: 500,
221 max_interval_seconds: 30,
222 api_version: "v9.2".to_string(),
223 };
224 assert!(config.validate_with_identity_provider().is_ok());
225 }
226
227 #[test]
228 fn test_config_validation_empty_url() {
229 let config = DataverseSourceConfig {
230 environment_url: String::new(),
231 tenant_id: "t".to_string(),
232 client_id: "c".to_string(),
233 client_secret: "s".to_string(),
234 entities: vec!["account".to_string()],
235 entity_set_overrides: HashMap::new(),
236 entity_columns: HashMap::new(),
237 min_interval_ms: 500,
238 max_interval_seconds: 30,
239 api_version: "v9.2".to_string(),
240 };
241 assert!(config.validate().is_err());
242 }
243
244 #[test]
245 fn test_config_validation_empty_entities() {
246 let config = DataverseSourceConfig {
247 environment_url: "https://test.crm.dynamics.com".to_string(),
248 tenant_id: "t".to_string(),
249 client_id: "c".to_string(),
250 client_secret: "s".to_string(),
251 entities: vec![],
252 entity_set_overrides: HashMap::new(),
253 entity_columns: HashMap::new(),
254 min_interval_ms: 500,
255 max_interval_seconds: 30,
256 api_version: "v9.2".to_string(),
257 };
258 assert!(config.validate().is_err());
259 }
260
261 #[test]
262 fn test_entity_set_name_default() {
263 let config = DataverseSourceConfig {
264 environment_url: "https://test.crm.dynamics.com".to_string(),
265 tenant_id: "t".to_string(),
266 client_id: "c".to_string(),
267 client_secret: "s".to_string(),
268 entities: vec!["account".to_string()],
269 entity_set_overrides: HashMap::new(),
270 entity_columns: HashMap::new(),
271 min_interval_ms: 500,
272 max_interval_seconds: 30,
273 api_version: "v9.2".to_string(),
274 };
275 assert_eq!(config.entity_set_name("account"), "accounts");
276 assert_eq!(config.entity_set_name("contact"), "contacts");
277 }
278
279 #[test]
280 fn test_entity_set_name_override() {
281 let mut overrides = HashMap::new();
282 overrides.insert("activityparty".to_string(), "activityparties".to_string());
283 let config = DataverseSourceConfig {
284 environment_url: "https://test.crm.dynamics.com".to_string(),
285 tenant_id: "t".to_string(),
286 client_id: "c".to_string(),
287 client_secret: "s".to_string(),
288 entities: vec!["activityparty".to_string()],
289 entity_set_overrides: overrides,
290 entity_columns: HashMap::new(),
291 min_interval_ms: 500,
292 max_interval_seconds: 30,
293 api_version: "v9.2".to_string(),
294 };
295 assert_eq!(config.entity_set_name("activityparty"), "activityparties");
296 }
297
298 #[test]
299 fn test_select_columns() {
300 let mut cols = HashMap::new();
301 cols.insert(
302 "account".to_string(),
303 vec!["name".to_string(), "revenue".to_string()],
304 );
305 let config = DataverseSourceConfig {
306 environment_url: "https://test.crm.dynamics.com".to_string(),
307 tenant_id: "t".to_string(),
308 client_id: "c".to_string(),
309 client_secret: "s".to_string(),
310 entities: vec!["account".to_string()],
311 entity_set_overrides: HashMap::new(),
312 entity_columns: cols,
313 min_interval_ms: 500,
314 max_interval_seconds: 30,
315 api_version: "v9.2".to_string(),
316 };
317 assert_eq!(
319 config.select_columns("account"),
320 Some("name,revenue,accountid".to_string())
321 );
322 assert_eq!(config.select_columns("contact"), None);
323 }
324
325 #[test]
326 fn test_select_columns_appends_primary_key() {
327 let mut entity_columns = HashMap::new();
328 entity_columns.insert("account".to_string(), vec!["name".to_string()]);
329 let config = DataverseSourceConfig {
330 environment_url: "https://myorg.crm.dynamics.com".to_string(),
331 tenant_id: "t".to_string(),
332 client_id: "c".to_string(),
333 client_secret: "s".to_string(),
334 entities: vec!["account".to_string()],
335 entity_set_overrides: HashMap::new(),
336 entity_columns,
337 min_interval_ms: 500,
338 max_interval_seconds: 30,
339 api_version: "v9.2".to_string(),
340 };
341 assert_eq!(
342 config.select_columns("account"),
343 Some("name,accountid".to_string())
344 );
345 }
346
347 #[test]
348 fn test_select_columns_does_not_duplicate_primary_key() {
349 let mut entity_columns = HashMap::new();
350 entity_columns.insert(
351 "account".to_string(),
352 vec!["name".to_string(), "accountid".to_string()],
353 );
354 let config = DataverseSourceConfig {
355 environment_url: "https://myorg.crm.dynamics.com".to_string(),
356 tenant_id: "t".to_string(),
357 client_id: "c".to_string(),
358 client_secret: "s".to_string(),
359 entities: vec!["account".to_string()],
360 entity_set_overrides: HashMap::new(),
361 entity_columns,
362 min_interval_ms: 500,
363 max_interval_seconds: 30,
364 api_version: "v9.2".to_string(),
365 };
366 assert_eq!(
367 config.select_columns("account"),
368 Some("name,accountid".to_string())
369 );
370 }
371
372 #[test]
373 fn debug_redacts_client_secret() {
374 let config = DataverseSourceConfig {
377 environment_url: "https://myorg.crm.dynamics.com".to_string(),
378 tenant_id: "tenant-1".to_string(),
379 client_id: "client-1".to_string(),
380 client_secret: "super-secret-do-not-leak".to_string(),
381 entities: vec!["account".to_string()],
382 entity_set_overrides: HashMap::new(),
383 entity_columns: HashMap::new(),
384 min_interval_ms: 500,
385 max_interval_seconds: 30,
386 api_version: "v9.2".to_string(),
387 };
388 let dbg = format!("{config:?}");
389 assert!(
390 !dbg.contains("super-secret-do-not-leak"),
391 "client_secret must not appear in Debug output: {dbg}"
392 );
393 assert!(dbg.contains("[REDACTED]"));
394 assert!(dbg.contains("tenant-1"));
396 assert!(dbg.contains("client-1"));
397 }
398}