use async_trait::async_trait;
use chrono::{DateTime, Datelike, TimeZone, Utc};
use reqwest::{
Client,
header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue},
};
use crate::error::{CarbemError, Result};
use crate::models::{CarbonEmission, EmissionMetadata, EmissionQuery, TimePeriod};
use crate::providers::CarbonProvider;
use crate::providers::config::ProviderQueryConfig;
use super::models::*;
const AZURE_MANAGEMENT_BASE_URL: &str = "https://management.azure.com";
const CARBON_API_VERSION: &str = "2025-04-01";
#[derive(Debug, Clone)]
pub struct AzureProvider {
config: AzureConfig,
http_client: Client,
}
impl AzureProvider {
pub fn new(config: AzureConfig) -> Result<Self> {
let http_client = Client::new();
Ok(Self {
config,
http_client,
})
}
fn convert_emission_query_to_azure_request(
&self,
query: &EmissionQuery,
) -> Result<AzureCarbonEmissionReportRequest> {
let start_date = query.time_period.start.format("%Y-%m-%d").to_string();
let end_date = query.time_period.end.format("%Y-%m-%d").to_string();
let date_range = AzureDateRange {
start: start_date.clone(),
end: end_date.clone(),
};
let azure_config = match &query.provider_config {
Some(ProviderQueryConfig::Azure(config)) => config.clone(),
Some(_) => {
return Err(CarbemError::Config(
"provider_config must be Azure configuration for Azure provider".to_string(),
));
}
None => {
return Err(CarbemError::Config(
"provider_config with Azure configuration is required for Azure queries"
.to_string(),
));
}
};
azure_config.validate().map_err(CarbemError::Config)?;
let report_type_enum = &azure_config.report_type;
if matches!(
report_type_enum,
AzureReportType::ItemDetailsReport | AzureReportType::TopItemsSummaryReport
) && start_date != end_date
{
return Err(CarbemError::Config(format!(
"{} requires start and end dates to be the same (single month query)",
report_type_enum.as_str()
)));
}
let report_type = azure_config.report_type.as_str().to_string();
Ok(AzureCarbonEmissionReportRequest {
report_type,
subscription_list: azure_config.subscription_list.clone(),
carbon_scope_list: azure_config
.carbon_scope_list
.unwrap_or_else(|| {
vec![
AzureCarbonScope::Scope1,
AzureCarbonScope::Scope2,
AzureCarbonScope::Scope3,
]
})
.iter()
.map(|scope| scope.as_str().to_string())
.collect(),
date_range,
category_type: azure_config.category_type,
top_items: azure_config.top_items,
order_by: azure_config.order_by,
sort_direction: azure_config
.sort_direction
.as_ref()
.map(|sd| sd.as_str().to_string()),
page_size: azure_config.page_size,
location_list: if !query.regions.is_empty() {
Some(query.regions.clone())
} else {
None
},
resource_group_url_list: azure_config.resource_group_url_list,
resource_type_list: azure_config.resource_type_list,
skip_token: azure_config.skip_token,
})
}
fn build_headers(&self) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", self.config.access_token);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth_value)
.map_err(|e| CarbemError::Config(format!("Invalid access token: {}", e)))?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(headers)
}
#[allow(clippy::needless_return)]
fn build_request_payload(
&self,
query: &AzureCarbonEmissionReportRequest,
) -> AzureCarbonEmissionReportRequest {
return query.clone();
}
fn convert_to_carbon_emission(
&self,
data: &AzureEmissionData,
subscription_id: &str,
date_range: &AzureDateRange,
) -> CarbonEmission {
let mut provider_data = serde_json::Map::new();
provider_data.insert(
"dataType".to_string(),
serde_json::Value::String(data.data_type.clone()),
);
provider_data.insert(
"previousMonthEmissions".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(data.previous_month_emissions).unwrap(),
),
);
provider_data.insert(
"monthOverMonthEmissionsChangeRatio".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(data.month_over_month_emissions_change_ratio).unwrap(),
),
);
provider_data.insert(
"monthlyEmissionsChangeValue".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(data.monthly_emissions_change_value).unwrap(),
),
);
if let Some(date) = &data.date {
provider_data.insert("date".to_string(), serde_json::Value::String(date.clone()));
}
if let Some(item_name) = &data.item_name {
provider_data.insert(
"itemName".to_string(),
serde_json::Value::String(item_name.clone()),
);
}
if let Some(category_type) = &data.category_type {
provider_data.insert(
"categoryType".to_string(),
serde_json::Value::String(category_type.clone()),
);
}
let specific_time_period = if let Some(date) = &data.date {
match DateTime::parse_from_str(
&format!("{}T00:00:00+00:00", date),
"%Y-%m-%dT%H:%M:%S%z",
) {
Ok(start_date) => {
let start = start_date.with_timezone(&Utc);
let end = if start.month() == 12 {
start
.with_year(start.year() + 1)
.unwrap()
.with_month(1)
.unwrap()
} else {
start.with_month(start.month() + 1).unwrap()
};
TimePeriod { start, end }
}
Err(_) => {
let start = DateTime::parse_from_str(
&format!("{}T00:00:00+00:00", date_range.start),
"%Y-%m-%dT%H:%M:%S%z",
)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| {
DateTime::parse_from_str("2024-01-01T00:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap())
});
let end = DateTime::parse_from_str(
&format!("{}T00:00:00+00:00", date_range.end),
"%Y-%m-%dT%H:%M:%S%z",
)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| {
DateTime::parse_from_str("2024-01-02T00:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap())
});
TimePeriod { start, end }
}
}
} else {
let start = DateTime::parse_from_str(
&format!("{}T00:00:00+00:00", date_range.start),
"%Y-%m-%dT%H:%M:%S%z",
)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| {
DateTime::parse_from_str("2024-01-01T00:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap())
});
let end = DateTime::parse_from_str(
&format!("{}T00:00:00+00:00", date_range.end),
"%Y-%m-%dT%H:%M:%S%z",
)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| {
DateTime::parse_from_str("2024-01-02T00:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap())
});
TimePeriod { start, end }
};
let metadata = EmissionMetadata {
energy_kwh: None, grid_carbon_intensity: data.carbon_intensity, renewable_percentage: None, provider_data: Some(serde_json::Value::Object(provider_data)),
};
let region = data
.item_name
.as_ref()
.unwrap_or(&subscription_id.to_string())
.clone();
let service = data
.category_type
.as_ref()
.map(|ct| ct.to_lowercase())
.or_else(|| Some("overall".to_string()));
CarbonEmission {
provider: "azure".to_string(),
region,
service,
emissions_kg_co2eq: data.latest_month_emissions,
time_period: specific_time_period,
metadata: Some(metadata),
}
}
#[allow(clippy::redundant_closure)]
async fn request_carbon_emissions(
&self,
query: &AzureCarbonEmissionReportRequest,
) -> Result<Vec<CarbonEmission>> {
let url = format!(
"{}/providers/Microsoft.Carbon/carbonEmissionReports?api-version={}",
AZURE_MANAGEMENT_BASE_URL, CARBON_API_VERSION
);
let headers = self.build_headers()?;
let payload = self.build_request_payload(query);
let response = self
.http_client
.post(&url)
.headers(headers)
.json(&payload)
.send()
.await
.map_err(CarbemError::Http)?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(CarbemError::Provider(format!(
"Azure API request failed with status {}: {}",
status, body
)));
}
let azure_response: AzureCarbonEmissionReportResponse =
response.json().await.map_err(CarbemError::Http)?;
let mut allowed_subscriptions = Vec::new();
let mut denied_subscriptions = Vec::new();
if let Some(access_decisions) = &azure_response.subscription_access_decision_list {
for decision in access_decisions {
if decision.decision == "Allowed" {
allowed_subscriptions.push(decision.subscription_id.clone());
continue;
} else {
let reason = decision
.denial_reason
.as_ref()
.map(|r| format!(" ({})", r))
.unwrap_or_default();
denied_subscriptions.push(format!("{}{}", decision.subscription_id, reason));
}
}
} else {
allowed_subscriptions = query.subscription_list.clone();
}
if !denied_subscriptions.is_empty() {
if allowed_subscriptions.is_empty() {
return Err(CarbemError::Auth(format!(
"Access denied for all subscriptions: {}",
denied_subscriptions.join(", ")
)));
}
}
let mut emissions = Vec::new();
for data in azure_response.value {
for subscription_id in &allowed_subscriptions {
let emission =
self.convert_to_carbon_emission(&data, subscription_id, &query.date_range);
emissions.push(emission);
}
}
emissions.sort_by(|a, b| b.time_period.start.cmp(&a.time_period.start));
Ok(emissions)
}
}
#[async_trait]
impl CarbonProvider for AzureProvider {
fn name(&self) -> &'static str {
"azure"
}
async fn get_emissions(&self, query: &EmissionQuery) -> Result<Vec<CarbonEmission>> {
if query.provider != "azure" {
return Err(CarbemError::Config(
"Query provider must be 'azure' for AzureProvider".to_string(),
));
}
if query.regions.is_empty() {
return Err(CarbemError::Config(
"At least one subscription ID must be specified in the query".to_string(),
));
}
let azure_request = self.convert_emission_query_to_azure_request(query)?;
self.request_carbon_emissions(&azure_request).await
}
fn is_configured(&self) -> bool {
!self.config.access_token.is_empty()
}
fn clone_provider(&self) -> Box<dyn CarbonProvider + Send + Sync> {
Box::new(self.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{EmissionQuery, TimePeriod};
use chrono::{TimeZone, Utc};
fn create_test_provider() -> AzureProvider {
let config = AzureConfig {
access_token: "test-token".to_string(),
};
AzureProvider::new(config).unwrap()
}
fn create_test_emission_query() -> EmissionQuery {
EmissionQuery {
provider: "azure".to_string(),
regions: vec!["00000000-0000-0000-0000-000000000000".to_string()],
time_period: TimePeriod {
start: Utc.with_ymd_and_hms(2024, 3, 1, 0, 0, 0).unwrap(),
end: Utc.with_ymd_and_hms(2024, 5, 1, 0, 0, 0).unwrap(),
},
services: None,
resources: None,
provider_config: None, }
}
#[test]
fn test_azure_provider_creation() {
let config = AzureConfig {
access_token: "test-token".to_string(),
};
let provider = AzureProvider::new(config).unwrap();
assert_eq!(provider.name(), "azure");
assert!(provider.is_configured());
}
#[test]
fn test_azure_provider_not_configured_with_empty_token() {
let config = AzureConfig {
access_token: "".to_string(),
};
let provider = AzureProvider::new(config).unwrap();
assert!(!provider.is_configured());
}
#[test]
fn test_convert_emission_query_to_azure_request() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
..Default::default()
}));
let azure_request = provider
.convert_emission_query_to_azure_request(&query)
.unwrap();
assert_eq!(azure_request.report_type, "MonthlySummaryReport");
assert_eq!(
azure_request.subscription_list,
vec!["00000000-0000-0000-0000-000000000000"]
);
assert_eq!(
azure_request.carbon_scope_list,
vec!["Scope1", "Scope2", "Scope3"]
);
assert_eq!(azure_request.date_range.start, "2024-03-01");
assert_eq!(azure_request.date_range.end, "2024-05-01");
assert_eq!(azure_request.category_type, None);
assert_eq!(azure_request.top_items, None);
assert_eq!(azure_request.order_by, None);
assert_eq!(azure_request.sort_direction, None);
assert_eq!(azure_request.page_size, None);
}
#[test]
fn test_convert_emission_query_with_provider_config() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::ItemDetailsReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: None, category_type: Some("Location".to_string()),
order_by: Some("emissions".to_string()),
page_size: Some(100),
sort_direction: Some(AzureSortDirection::Desc),
top_items: None,
resource_group_url_list: None,
resource_type_list: None,
skip_token: None,
}));
query.time_period.end = query.time_period.start;
let azure_request = provider
.convert_emission_query_to_azure_request(&query)
.unwrap();
assert_eq!(azure_request.report_type, "ItemDetailsReport");
assert_eq!(azure_request.category_type, Some("Location".to_string()));
assert_eq!(azure_request.order_by, Some("emissions".to_string()));
assert_eq!(azure_request.sort_direction, Some("Desc".to_string()));
assert_eq!(azure_request.page_size, Some(100));
assert_eq!(
azure_request.carbon_scope_list,
vec!["Scope1", "Scope2", "Scope3"]
);
assert_eq!(
azure_request.location_list,
Some(vec!["00000000-0000-0000-0000-000000000000".to_string()])
);
assert_eq!(azure_request.resource_group_url_list, None);
assert_eq!(azure_request.resource_type_list, None);
assert_eq!(azure_request.skip_token, None);
}
#[test]
fn test_item_details_report_missing_required_fields() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.time_period.end = query.time_period.start;
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::ItemDetailsReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: None,
category_type: None, order_by: None, page_size: None, sort_direction: None, top_items: None,
resource_group_url_list: None,
resource_type_list: None,
skip_token: None,
}));
let result = provider.convert_emission_query_to_azure_request(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("category_type is required")
);
}
#[test]
fn test_item_details_report_multi_month_error() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::ItemDetailsReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: None,
category_type: Some("Location".to_string()),
order_by: Some("emissions".to_string()),
page_size: Some(100),
sort_direction: Some(AzureSortDirection::Desc),
top_items: None,
resource_group_url_list: None,
resource_type_list: None,
skip_token: None,
}));
let result = provider.convert_emission_query_to_azure_request(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("requires start and end dates to be the same")
);
}
#[test]
fn test_top_items_summary_report_missing_required_fields() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.time_period.end = query.time_period.start;
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::TopItemsSummaryReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: None,
category_type: None, order_by: None,
page_size: None,
sort_direction: None,
top_items: None, resource_group_url_list: None,
resource_type_list: None,
skip_token: None,
}));
let result = provider.convert_emission_query_to_azure_request(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("category_type is required")
);
}
#[test]
fn test_top_items_monthly_summary_report_with_optional_filters() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::TopItemsMonthlySummaryReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: Some(vec![AzureCarbonScope::Scope1]),
category_type: Some("Location".to_string()),
order_by: None,
page_size: None,
sort_direction: None,
top_items: Some(5),
resource_group_url_list: Some(vec![
"/subscriptions/sub-id/resourcegroups/rg1".to_string(),
]),
resource_type_list: Some(vec!["microsoft.compute/virtualmachines".to_string()]),
skip_token: None,
}));
let azure_request = provider
.convert_emission_query_to_azure_request(&query)
.unwrap();
assert_eq!(azure_request.report_type, "TopItemsMonthlySummaryReport");
assert_eq!(azure_request.category_type, Some("Location".to_string()));
assert_eq!(azure_request.top_items, Some(5));
assert_eq!(
azure_request.location_list,
Some(vec!["00000000-0000-0000-0000-000000000000".to_string()])
);
assert_eq!(
azure_request.resource_group_url_list,
Some(vec!["/subscriptions/sub-id/resourcegroups/rg1".to_string()])
);
assert_eq!(
azure_request.resource_type_list,
Some(vec!["microsoft.compute/virtualmachines".to_string()])
);
}
#[test]
fn test_page_size_validation() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.time_period.end = query.time_period.start;
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::ItemDetailsReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: None,
category_type: Some("Location".to_string()),
order_by: Some("emissions".to_string()),
page_size: Some(6000), sort_direction: Some(AzureSortDirection::Desc),
top_items: None,
resource_group_url_list: None,
resource_type_list: None,
skip_token: None,
}));
let result = provider.convert_emission_query_to_azure_request(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("page_size must be between 1 and 5000")
);
}
#[test]
fn test_top_items_validation() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.time_period.end = query.time_period.start;
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
report_type: AzureReportType::TopItemsSummaryReport,
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
carbon_scope_list: None,
category_type: Some("Location".to_string()),
order_by: None,
page_size: None,
sort_direction: None,
top_items: Some(20), resource_group_url_list: None,
resource_type_list: None,
skip_token: None,
}));
let result = provider.convert_emission_query_to_azure_request(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("top_items must be between 1 and 10")
);
}
#[test]
fn test_build_headers() {
let provider = create_test_provider();
let headers = provider.build_headers().unwrap();
assert!(headers.contains_key("authorization"));
assert!(headers.contains_key("content-type"));
let auth_header = headers.get("authorization").unwrap().to_str().unwrap();
assert_eq!(auth_header, "Bearer test-token");
let content_type = headers.get("content-type").unwrap().to_str().unwrap();
assert_eq!(content_type, "application/json");
}
#[test]
fn test_build_headers_invalid_token() {
let config = AzureConfig {
access_token: "invalid\ntoken".to_string(),
};
let provider = AzureProvider::new(config).unwrap();
let result = provider.build_headers();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid access token")
);
}
#[test]
fn test_missing_provider_config() {
let provider = create_test_provider();
let query = create_test_emission_query();
let result = provider.convert_emission_query_to_azure_request(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("provider_config with Azure configuration is required")
);
}
#[test]
fn test_build_request_payload() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.provider_config = Some(ProviderQueryConfig::Azure(AzureQueryConfig {
subscription_list: vec!["00000000-0000-0000-0000-000000000000".to_string()],
..Default::default()
}));
let azure_request = provider
.convert_emission_query_to_azure_request(&query)
.unwrap();
let payload = provider.build_request_payload(&azure_request);
assert_eq!(payload.report_type, azure_request.report_type);
assert_eq!(payload.subscription_list, azure_request.subscription_list);
assert_eq!(payload.carbon_scope_list, azure_request.carbon_scope_list);
assert_eq!(payload.date_range.start, azure_request.date_range.start);
assert_eq!(payload.date_range.end, azure_request.date_range.end);
}
#[test]
fn test_convert_to_carbon_emission_basic() {
let provider = create_test_provider();
let date_range = AzureDateRange {
start: "2024-03-01".to_string(),
end: "2024-05-01".to_string(),
};
let azure_data = AzureEmissionData {
data_type: "MonthlySummaryData".to_string(),
latest_month_emissions: 0.1,
previous_month_emissions: 0.05,
month_over_month_emissions_change_ratio: 1.0,
monthly_emissions_change_value: 0.05,
date: Some("2024-05-01".to_string()),
carbon_intensity: Some(22.0),
item_name: None,
category_type: None,
};
let emission =
provider.convert_to_carbon_emission(&azure_data, "test-subscription", &date_range);
assert_eq!(emission.provider, "azure");
assert_eq!(emission.region, "test-subscription");
assert_eq!(emission.service, Some("overall".to_string()));
assert_eq!(emission.emissions_kg_co2eq, 0.1);
assert!(emission.metadata.is_some());
let metadata = emission.metadata.unwrap();
assert_eq!(metadata.grid_carbon_intensity, Some(22.0));
assert!(metadata.provider_data.is_some());
let provider_data = metadata.provider_data.unwrap();
assert_eq!(provider_data["dataType"], "MonthlySummaryData");
assert_eq!(provider_data["date"], "2024-05-01");
assert_eq!(provider_data["previousMonthEmissions"], 0.05);
assert_eq!(provider_data["monthOverMonthEmissionsChangeRatio"], 1.0);
assert_eq!(provider_data["monthlyEmissionsChangeValue"], 0.05);
}
#[test]
fn test_convert_to_carbon_emission_with_item_name() {
let provider = create_test_provider();
let date_range = AzureDateRange {
start: "2024-03-01".to_string(),
end: "2024-05-01".to_string(),
};
let azure_data = AzureEmissionData {
data_type: "TopItemsSummaryData".to_string(),
latest_month_emissions: 0.1,
previous_month_emissions: 0.05,
month_over_month_emissions_change_ratio: 1.0,
monthly_emissions_change_value: 0.05,
date: None,
carbon_intensity: None,
item_name: Some("east us".to_string()),
category_type: Some("Location".to_string()),
};
let emission =
provider.convert_to_carbon_emission(&azure_data, "test-subscription", &date_range);
assert_eq!(emission.provider, "azure");
assert_eq!(emission.region, "east us"); assert_eq!(emission.service, Some("location".to_string())); assert_eq!(emission.emissions_kg_co2eq, 0.1);
let metadata = emission.metadata.unwrap();
let provider_data = metadata.provider_data.unwrap();
assert_eq!(provider_data["itemName"], "east us");
assert_eq!(provider_data["categoryType"], "Location");
}
#[test]
fn test_convert_to_carbon_emission_with_date_parsing() {
let provider = create_test_provider();
let date_range = AzureDateRange {
start: "2024-03-01".to_string(),
end: "2024-05-01".to_string(),
};
let azure_data = AzureEmissionData {
data_type: "MonthlySummaryData".to_string(),
latest_month_emissions: 0.1,
previous_month_emissions: 0.05,
month_over_month_emissions_change_ratio: 1.0,
monthly_emissions_change_value: 0.05,
date: Some("2024-05-01".to_string()),
carbon_intensity: Some(22.0),
item_name: None,
category_type: None,
};
let emission =
provider.convert_to_carbon_emission(&azure_data, "test-subscription", &date_range);
let expected_start = Utc.with_ymd_and_hms(2024, 5, 1, 0, 0, 0).unwrap();
let expected_end = Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap();
assert_eq!(emission.time_period.start, expected_start);
assert_eq!(emission.time_period.end, expected_end);
}
#[test]
fn test_get_emissions_wrong_provider() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.provider = "aws".to_string();
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(provider.get_emissions(&query));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Query provider must be 'azure'")
);
}
#[test]
fn test_get_emissions_no_regions() {
let provider = create_test_provider();
let mut query = create_test_emission_query();
query.regions = vec![];
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(provider.get_emissions(&query));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("At least one subscription ID must be specified")
);
}
#[test]
fn test_month_end_calculation_december() {
let provider = create_test_provider();
let date_range = AzureDateRange {
start: "2024-12-01".to_string(),
end: "2024-12-31".to_string(),
};
let azure_data = AzureEmissionData {
data_type: "MonthlySummaryData".to_string(),
latest_month_emissions: 0.1,
previous_month_emissions: 0.05,
month_over_month_emissions_change_ratio: 1.0,
monthly_emissions_change_value: 0.05,
date: Some("2024-12-01".to_string()),
carbon_intensity: None,
item_name: None,
category_type: None,
};
let emission =
provider.convert_to_carbon_emission(&azure_data, "test-subscription", &date_range);
let expected_start = Utc.with_ymd_and_hms(2024, 12, 1, 0, 0, 0).unwrap();
let expected_end = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
assert_eq!(emission.time_period.start, expected_start);
assert_eq!(emission.time_period.end, expected_end);
}
#[test]
fn test_overall_summary_data_conversion() {
let provider = create_test_provider();
let date_range = AzureDateRange {
start: "2024-03-01".to_string(),
end: "2024-05-01".to_string(),
};
let azure_data = AzureEmissionData {
data_type: "OverallSummaryData".to_string(),
latest_month_emissions: 0.1,
previous_month_emissions: 0.05,
month_over_month_emissions_change_ratio: 1.0,
monthly_emissions_change_value: 0.05,
date: None,
carbon_intensity: None,
item_name: None,
category_type: None,
};
let emission =
provider.convert_to_carbon_emission(&azure_data, "test-subscription", &date_range);
assert_eq!(emission.provider, "azure");
assert_eq!(emission.region, "test-subscription"); assert_eq!(emission.service, Some("overall".to_string())); assert_eq!(emission.emissions_kg_co2eq, 0.1);
let expected_start = Utc.with_ymd_and_hms(2024, 3, 1, 0, 0, 0).unwrap();
let expected_end = Utc.with_ymd_and_hms(2024, 5, 1, 0, 0, 0).unwrap();
assert_eq!(emission.time_period.start, expected_start);
assert_eq!(emission.time_period.end, expected_end);
let metadata = emission.metadata.unwrap();
let provider_data = metadata.provider_data.unwrap();
assert_eq!(provider_data["dataType"], "OverallSummaryData");
}
#[tokio::test]
#[ignore] async fn test_get_emissions_integration() {
if let Ok(access_token) = std::env::var("AZURE_ACCESS_TOKEN") {
let config = AzureConfig { access_token };
let provider = AzureProvider::new(config).unwrap();
let query = EmissionQuery {
provider: "azure".to_string(),
regions: vec!["your-subscription-id".to_string()],
time_period: TimePeriod {
start: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
end: Utc.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap(),
},
services: None,
resources: None,
provider_config: None, };
let result = provider.get_emissions(&query).await;
assert!(result.is_ok());
}
}
}