use std::collections::HashMap;
#[derive(Clone)]
pub struct DataverseSourceConfig {
pub environment_url: String,
pub tenant_id: String,
pub client_id: String,
pub client_secret: String,
pub entities: Vec<String>,
pub entity_set_overrides: HashMap<String, String>,
pub entity_columns: HashMap<String, Vec<String>>,
pub min_interval_ms: u64,
pub max_interval_seconds: u64,
pub api_version: String,
}
impl std::fmt::Debug for DataverseSourceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DataverseSourceConfig")
.field("environment_url", &self.environment_url)
.field("tenant_id", &self.tenant_id)
.field("client_id", &self.client_id)
.field("client_secret", &"[REDACTED]")
.field("entities", &self.entities)
.field("entity_set_overrides", &self.entity_set_overrides)
.field("entity_columns", &self.entity_columns)
.field("min_interval_ms", &self.min_interval_ms)
.field("max_interval_seconds", &self.max_interval_seconds)
.field("api_version", &self.api_version)
.finish()
}
}
impl DataverseSourceConfig {
pub fn validate(&self) -> Result<(), String> {
if self.environment_url.is_empty() {
return Err("environment_url is required".to_string());
}
if self.tenant_id.is_empty() {
return Err("tenant_id is required (or configure an identity provider)".to_string());
}
if self.client_id.is_empty() {
return Err("client_id is required (or configure an identity provider)".to_string());
}
if self.client_secret.is_empty() {
return Err(
"client_secret is required (or configure an identity provider)".to_string(),
);
}
if self.entities.is_empty() {
return Err("entities list must not be empty".to_string());
}
Ok(())
}
pub fn validate_with_identity_provider(&self) -> Result<(), String> {
if self.environment_url.is_empty() {
return Err("environment_url is required".to_string());
}
if self.entities.is_empty() {
return Err("entities list must not be empty".to_string());
}
Ok(())
}
pub fn entity_set_name(&self, entity: &str) -> String {
if let Some(override_name) = self.entity_set_overrides.get(entity) {
override_name.clone()
} else {
format!("{entity}s")
}
}
pub fn select_columns(&self, entity: &str) -> Option<String> {
self.entity_columns.get(entity).map(|cols| {
let primary_key = format!("{entity}id");
let has_primary_key = cols.iter().any(|c| c.eq_ignore_ascii_case(&primary_key));
if has_primary_key {
cols.join(",")
} else {
let mut all_columns = cols.clone();
all_columns.push(primary_key);
all_columns.join(",")
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_validation_success() {
let config = DataverseSourceConfig {
environment_url: "https://myorg.crm.dynamics.com".to_string(),
tenant_id: "tenant-1".to_string(),
client_id: "client-1".to_string(),
client_secret: "secret-1".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_with_identity_provider_skips_credentials() {
let config = DataverseSourceConfig {
environment_url: "https://myorg.crm.dynamics.com".to_string(),
tenant_id: String::new(),
client_id: String::new(),
client_secret: String::new(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert!(config.validate_with_identity_provider().is_ok());
}
#[test]
fn test_config_validation_empty_url() {
let config = DataverseSourceConfig {
environment_url: String::new(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert!(config.validate().is_err());
}
#[test]
fn test_config_validation_empty_entities() {
let config = DataverseSourceConfig {
environment_url: "https://test.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec![],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert!(config.validate().is_err());
}
#[test]
fn test_entity_set_name_default() {
let config = DataverseSourceConfig {
environment_url: "https://test.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert_eq!(config.entity_set_name("account"), "accounts");
assert_eq!(config.entity_set_name("contact"), "contacts");
}
#[test]
fn test_entity_set_name_override() {
let mut overrides = HashMap::new();
overrides.insert("activityparty".to_string(), "activityparties".to_string());
let config = DataverseSourceConfig {
environment_url: "https://test.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["activityparty".to_string()],
entity_set_overrides: overrides,
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert_eq!(config.entity_set_name("activityparty"), "activityparties");
}
#[test]
fn test_select_columns() {
let mut cols = HashMap::new();
cols.insert(
"account".to_string(),
vec!["name".to_string(), "revenue".to_string()],
);
let config = DataverseSourceConfig {
environment_url: "https://test.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: cols,
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert_eq!(
config.select_columns("account"),
Some("name,revenue,accountid".to_string())
);
assert_eq!(config.select_columns("contact"), None);
}
#[test]
fn test_select_columns_appends_primary_key() {
let mut entity_columns = HashMap::new();
entity_columns.insert("account".to_string(), vec!["name".to_string()]);
let config = DataverseSourceConfig {
environment_url: "https://myorg.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns,
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert_eq!(
config.select_columns("account"),
Some("name,accountid".to_string())
);
}
#[test]
fn test_select_columns_does_not_duplicate_primary_key() {
let mut entity_columns = HashMap::new();
entity_columns.insert(
"account".to_string(),
vec!["name".to_string(), "accountid".to_string()],
);
let config = DataverseSourceConfig {
environment_url: "https://myorg.crm.dynamics.com".to_string(),
tenant_id: "t".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns,
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
assert_eq!(
config.select_columns("account"),
Some("name,accountid".to_string())
);
}
#[test]
fn debug_redacts_client_secret() {
let config = DataverseSourceConfig {
environment_url: "https://myorg.crm.dynamics.com".to_string(),
tenant_id: "tenant-1".to_string(),
client_id: "client-1".to_string(),
client_secret: "super-secret-do-not-leak".to_string(),
entities: vec!["account".to_string()],
entity_set_overrides: HashMap::new(),
entity_columns: HashMap::new(),
min_interval_ms: 500,
max_interval_seconds: 30,
api_version: "v9.2".to_string(),
};
let dbg = format!("{config:?}");
assert!(
!dbg.contains("super-secret-do-not-leak"),
"client_secret must not appear in Debug output: {dbg}"
);
assert!(dbg.contains("[REDACTED]"));
assert!(dbg.contains("tenant-1"));
assert!(dbg.contains("client-1"));
}
}