seamless-glance 1.0.0

Fast, read-only AWS TUI for cloud infrastructure visibility and triage
use std::collections::BTreeMap;

use aws_sdk_costexplorer::types::{DateInterval, Granularity, GroupDefinition, Metric};
use chrono::{Datelike, Months, NaiveDate, Utc};

use crate::{
    app::App,
    models::{BudgetInfo, ServiceCostInsight, UsageTypeCost},
};

fn today_exclusive() -> NaiveDate {
    Utc::now().date_naive()
}

fn first_day_of_month(date: NaiveDate) -> NaiveDate {
    NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid first day of month")
}

fn first_day_of_next_month(date: NaiveDate) -> NaiveDate {
    if date.month() == 12 {
        NaiveDate::from_ymd_opt(date.year() + 1, 1, 1).expect("valid first day of next year")
    } else {
        NaiveDate::from_ymd_opt(date.year(), date.month() + 1, 1)
            .expect("valid first day of next month")
    }
}

fn interval_strings(start: NaiveDate, end_exclusive: NaiveDate) -> (String, String) {
    (
        start.format("%Y-%m-%d").to_string(),
        end_exclusive.format("%Y-%m-%d").to_string(),
    )
}

fn interval(start: NaiveDate, end_exclusive: NaiveDate) -> DateInterval {
    let (start, end) = interval_strings(start, end_exclusive);

    DateInterval::builder()
        .start(start)
        .end(end)
        .build()
        .expect("valid Cost Explorer interval")
}

fn current_month_dates() -> (NaiveDate, NaiveDate) {
    let today = today_exclusive();
    (first_day_of_month(today), today)
}

fn forecast_month_dates() -> (NaiveDate, NaiveDate) {
    let today = today_exclusive();
    (today, first_day_of_next_month(today))
}

fn trailing_six_month_dates() -> (NaiveDate, NaiveDate) {
    let today = today_exclusive();
    let start = first_day_of_month(
        today
            .checked_sub_months(Months::new(5))
            .expect("valid six month window"),
    );

    (start, today)
}

pub fn last_6_month_labels() -> Vec<String> {
    let now = today_exclusive();

    (0..6)
        .map(|i| {
            now.checked_sub_months(Months::new((5 - i) as u32))
                .expect("valid month")
                .format("%b")
                .to_string()
        })
        .collect()
}

fn metric_amount(metric_value: Option<&aws_sdk_costexplorer::types::MetricValue>) -> f64 {
    metric_value
        .and_then(|metric| metric.amount())
        .and_then(|amount| amount.parse::<f64>().ok())
        .unwrap_or(0.0)
}

fn metric_unit(metric_value: Option<&aws_sdk_costexplorer::types::MetricValue>) -> String {
    metric_value
        .and_then(|metric| metric.unit())
        .unwrap_or("")
        .to_string()
}

pub async fn fetch_service_cost_insights(app: &App) -> Vec<ServiceCostInsight> {
    let (start, end) = current_month_dates();
    let service_group = GroupDefinition::builder()
        .key("SERVICE")
        .r#type("DIMENSION".into())
        .build();
    let usage_type_group = GroupDefinition::builder()
        .key("USAGE_TYPE")
        .r#type("DIMENSION".into())
        .build();

    let response = match app
        .aws
        .ce
        .get_cost_and_usage()
        .time_period(interval(start, end))
        .granularity(Granularity::Monthly)
        .metrics(Metric::UnblendedCost.as_str())
        .metrics(Metric::UsageQuantity.as_str())
        .group_by(service_group)
        .group_by(usage_type_group)
        .send()
        .await
    {
        Ok(response) => response,
        Err(err) => {
            eprintln!("Cost Explorer service usage error: {:?}", err);
            return vec![];
        }
    };

    let mut usage_by_service = BTreeMap::<String, Vec<UsageTypeCost>>::new();

    if let Some(result) = response.results_by_time().first() {
        for group in result.groups() {
            let service = group
                .keys()
                .first()
                .cloned()
                .unwrap_or_else(|| "Unknown".to_string());
            let usage_type = group
                .keys()
                .get(1)
                .cloned()
                .unwrap_or_else(|| "Unknown usage".to_string());

            let metrics = group.metrics();
            let monthly_cost =
                metric_amount(metrics.and_then(|values| values.get("UnblendedCost")));
            let usage_amount =
                metric_amount(metrics.and_then(|values| values.get("UsageQuantity")));
            let unit = metric_unit(metrics.and_then(|values| values.get("UsageQuantity")));

            if monthly_cost <= 0.0 && usage_amount <= 0.0 {
                continue;
            }

            usage_by_service
                .entry(service)
                .or_default()
                .push(UsageTypeCost {
                    usage_type,
                    monthly_cost,
                    usage_amount,
                    unit,
                });
        }
    }

    usage_by_service
        .into_iter()
        .filter_map(|(service, mut usage_lines)| {
            usage_lines.sort_by(|left, right| {
                right
                    .monthly_cost
                    .partial_cmp(&left.monthly_cost)
                    .unwrap_or(std::cmp::Ordering::Equal)
            });

            let monthly_cost = usage_lines
                .iter()
                .map(|usage| usage.monthly_cost)
                .sum::<f64>();

            (monthly_cost > 0.0).then_some(ServiceCostInsight {
                service,
                monthly_cost,
                top_usage_types: usage_lines.into_iter().take(3).collect(),
            })
        })
        .collect()
}

pub async fn fetch_last_6_month_costs(app: &App) -> Vec<f64> {
    let (start, end) = trailing_six_month_dates();

    let response = match app
        .aws
        .ce
        .get_cost_and_usage()
        .time_period(interval(start, end))
        .granularity(Granularity::Monthly)
        .metrics(Metric::UnblendedCost.as_str())
        .send()
        .await
    {
        Ok(response) => response,
        Err(err) => {
            eprintln!("Cost Explorer trailing monthly cost error: {:?}", err);
            return vec![0.0; 6];
        }
    };

    let mut values = response
        .results_by_time()
        .iter()
        .map(|time_bucket| {
            metric_amount(
                time_bucket
                    .total()
                    .and_then(|total| total.get("UnblendedCost")),
            )
        })
        .collect::<Vec<_>>();

    while values.len() < 6 {
        values.insert(0, 0.0);
    }

    values.truncate(6);
    values
}

pub async fn fetch_budget(app: &App) -> BudgetInfo {
    let (month_start, today) = current_month_dates();
    let (forecast_start, forecast_end) = forecast_month_dates();

    let actuals_response = match app
        .aws
        .ce
        .get_cost_and_usage()
        .time_period(interval(month_start, today))
        .granularity(Granularity::Monthly)
        .metrics(Metric::UnblendedCost.as_str())
        .send()
        .await
    {
        Ok(response) => response,
        Err(err) => {
            eprintln!("Cost Explorer month-to-date error: {:?}", err);
            return BudgetInfo::default();
        }
    };

    let month_to_date_cost = actuals_response
        .results_by_time()
        .first()
        .map(|time_bucket| {
            metric_amount(
                time_bucket
                    .total()
                    .and_then(|total| total.get("UnblendedCost")),
            )
        })
        .unwrap_or(0.0);

    let forecast_response = app
        .aws
        .ce
        .get_cost_forecast()
        .time_period(interval(forecast_start, forecast_end))
        .metric(Metric::UnblendedCost)
        .granularity(Granularity::Monthly)
        .prediction_interval_level(80)
        .send()
        .await;

    let (forecast, forecast_low, forecast_high) = match forecast_response {
        Ok(response) => {
            let remaining = metric_amount(response.total());
            let range = response.forecast_results_by_time().first();
            let low = range
                .and_then(|forecast| forecast.prediction_interval_lower_bound())
                .and_then(|value| value.parse::<f64>().ok())
                .map(|value| month_to_date_cost + value);
            let high = range
                .and_then(|forecast| forecast.prediction_interval_upper_bound())
                .and_then(|value| value.parse::<f64>().ok())
                .map(|value| month_to_date_cost + value);

            (month_to_date_cost + remaining, low, high)
        }
        Err(err) => {
            eprintln!("Cost Explorer forecast error: {:?}", err);
            (month_to_date_cost, None, None)
        }
    };

    BudgetInfo {
        monthly_budget: 100.0,
        month_to_date_cost,
        forecast,
        forecast_low,
        forecast_high,
    }
}