1use crate::cli::DatabricksCli;
2use crate::fetchers::preview::run_sql;
3use crate::shape::TableData;
4
5const BUCKET_CASE: &str = "CASE \
6 WHEN u.sku_name LIKE '%JOBS%' THEN 'Jobs' \
7 WHEN u.sku_name LIKE '%DLT%' THEN 'DLT' \
8 WHEN u.sku_name LIKE '%SQL%' THEN 'SQL' \
9 WHEN u.sku_name LIKE '%ALL_PURPOSE%' THEN 'All-Purpose' \
10 ELSE 'Other' END";
11
12const PRICE_JOIN: &str = "LEFT JOIN system.billing.list_prices lp \
14 ON u.sku_name = lp.sku_name AND u.usage_unit = lp.usage_unit \
15 AND u.usage_end_time >= lp.price_start_time \
16 AND (lp.price_end_time IS NULL OR u.usage_end_time < lp.price_end_time)";
17
18#[derive(Debug, Clone)]
19pub struct CostDay {
20 pub date: String,
21 pub by_bucket: Vec<(String, f64)>,
22 pub total: f64,
23 pub total_usd: f64,
24}
25
26#[derive(Debug, Clone)]
28pub struct Spender {
29 pub kind: String,
30 pub id: String,
31 pub dbus: f64,
32 pub usd: f64,
33}
34
35#[derive(Debug, Clone)]
36pub struct CostData {
37 pub days: Vec<CostDay>,
38 pub buckets: Vec<(String, f64, f64)>,
40 pub total: f64,
41 pub total_usd: f64,
42 pub priced: bool,
44 pub spenders: Vec<Spender>,
46 pub scoped: bool,
49}
50
51fn ws_clause(workspace_id: Option<&str>) -> String {
53 match workspace_id {
54 Some(id) => format!(" AND u.workspace_id = '{}'", id.replace('\'', "")),
55 None => String::new(),
56 }
57}
58
59fn priced_query(ws: &str) -> String {
60 format!(
61 "SELECT u.usage_date, {BUCKET_CASE} AS bucket, \
62 ROUND(SUM(u.usage_quantity), 2) AS dbus, \
63 ROUND(SUM(u.usage_quantity * COALESCE(lp.pricing.default, 0)), 2) AS usd \
64 FROM system.billing.usage u {PRICE_JOIN} \
65 WHERE u.usage_date >= date_sub(current_date(), 13){ws} \
66 GROUP BY 1, 2 ORDER BY 1"
67 )
68}
69
70const SPENDER_KIND: &str = "CASE \
71 WHEN u.usage_metadata.job_id IS NOT NULL THEN 'job' \
72 WHEN u.usage_metadata.warehouse_id IS NOT NULL THEN 'warehouse' \
73 WHEN u.usage_metadata.cluster_id IS NOT NULL THEN 'cluster' \
74 ELSE 'other' END";
75
76const SPENDER_ID: &str = "COALESCE(u.usage_metadata.job_id, \
77 u.usage_metadata.warehouse_id, u.usage_metadata.cluster_id, u.sku_name)";
78
79fn spenders_query(priced: bool, ws: &str) -> String {
80 let usd = if priced {
81 ", ROUND(SUM(u.usage_quantity * COALESCE(lp.pricing.default, 0)), 2) AS usd"
82 } else {
83 ""
84 };
85 let join = if priced {
86 format!("{PRICE_JOIN} ")
87 } else {
88 String::new()
89 };
90 let order = if priced { "4" } else { "3" };
92 format!(
93 "SELECT {SPENDER_KIND} AS kind, {SPENDER_ID} AS id, \
94 ROUND(SUM(u.usage_quantity), 2) AS dbus{usd} \
95 FROM system.billing.usage u {join}\
96 WHERE u.usage_date >= date_sub(current_date(), 13){ws} \
97 GROUP BY 1, 2 ORDER BY {order} DESC LIMIT 10"
98 )
99}
100
101fn parse_spenders(table: &TableData) -> Vec<Spender> {
102 table
103 .rows
104 .iter()
105 .filter_map(|row| {
106 let (kind, id, dbus, usd) = match row.as_slice() {
107 [k, i, d, u] => (k, i, d.parse().ok()?, u.parse().unwrap_or(0.0)),
108 [k, i, d] => (k, i, d.parse().ok()?, 0.0),
109 _ => return None,
110 };
111 Some(Spender {
112 kind: kind.clone(),
113 id: id.clone(),
114 dbus,
115 usd,
116 })
117 })
118 .collect()
119}
120
121fn plain_query(ws: &str) -> String {
122 format!(
123 "SELECT u.usage_date, {BUCKET_CASE} AS bucket, \
124 ROUND(SUM(u.usage_quantity), 2) AS dbus \
125 FROM system.billing.usage u \
126 WHERE u.usage_date >= date_sub(current_date(), 13){ws} \
127 GROUP BY 1, 2 ORDER BY 1"
128 )
129}
130
131fn aggregate(table: &TableData, priced: bool) -> CostData {
132 let mut days: Vec<CostDay> = Vec::new();
133 let mut bucket_totals: Vec<(String, f64, f64)> = Vec::new();
134 for row in &table.rows {
135 let (date, bucket, dbus, usd) = match row.as_slice() {
136 [d, b, v, u] => (d, b, v.parse().unwrap_or(0.0), u.parse().unwrap_or(0.0)),
137 [d, b, v] => (d, b, v.parse().unwrap_or(0.0), 0.0),
138 _ => continue,
139 };
140 if days.last().map(|d| &d.date) != Some(date) {
141 days.push(CostDay {
142 date: date.clone(),
143 by_bucket: Vec::new(),
144 total: 0.0,
145 total_usd: 0.0,
146 });
147 }
148 let day = days.last_mut().unwrap();
149 day.by_bucket.push((bucket.clone(), dbus));
150 day.total += dbus;
151 day.total_usd += usd;
152 match bucket_totals.iter_mut().find(|(b, _, _)| b == bucket) {
153 Some((_, t, tu)) => {
154 *t += dbus;
155 *tu += usd;
156 }
157 None => bucket_totals.push((bucket.clone(), dbus, usd)),
158 }
159 }
160 bucket_totals.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
161 let total = bucket_totals.iter().map(|(_, t, _)| t).sum();
162 let total_usd = bucket_totals.iter().map(|(_, _, u)| u).sum();
163
164 CostData {
165 days,
166 buckets: bucket_totals,
167 total,
168 total_usd,
169 priced,
170 spenders: Vec::new(),
171 scoped: false,
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum ResourceKind {
178 Job,
179 Pipeline,
180}
181
182impl ResourceKind {
183 pub fn label(self) -> &'static str {
184 match self {
185 ResourceKind::Job => "job",
186 ResourceKind::Pipeline => "pipeline",
187 }
188 }
189
190 fn column(self) -> &'static str {
192 match self {
193 ResourceKind::Job => "job_id",
194 ResourceKind::Pipeline => "dlt_pipeline_id",
195 }
196 }
197}
198
199pub const WINDOWS: [(&str, i64); 4] = [
202 ("last week", 7),
203 ("last month", 30),
204 ("last quarter", 90),
205 ("last year", 365),
206];
207
208#[derive(Debug, Clone)]
210pub struct CostWindow {
211 pub label: &'static str,
212 pub days: i64,
213 pub dbus: f64,
214 pub usd: f64,
215 pub prior: Option<(f64, f64)>,
218}
219
220impl CostWindow {
221 pub fn trend(&self, priced: bool) -> Option<f64> {
225 let (prior_dbus, prior_usd) = self.prior?;
226 let (now, before) = if priced {
227 (self.usd, prior_usd)
228 } else {
229 (self.dbus, prior_dbus)
230 };
231 if before <= 0.0 {
232 return None;
233 }
234 Some((now - before) / before)
235 }
236}
237
238#[derive(Debug, Clone)]
240pub struct ResourceCost {
241 pub kind: ResourceKind,
242 pub windows: Vec<CostWindow>,
243 pub months: Vec<(String, f64, f64)>,
246 pub priced: bool,
248 pub scoped: bool,
250}
251
252impl ResourceCost {
253 pub fn is_empty(&self) -> bool {
254 self.months.is_empty()
255 }
256}
257
258fn resource_clause(kind: ResourceKind, id: &str) -> String {
260 format!(
261 " AND u.usage_metadata.{} = '{}'",
262 kind.column(),
263 id.replace('\'', "")
264 )
265}
266
267fn resource_query(priced: bool, kind: ResourceKind, id: &str, ws: &str) -> String {
271 let usd = if priced {
272 ", ROUND(SUM(u.usage_quantity * COALESCE(lp.pricing.default, 0)), 4) AS usd"
273 } else {
274 ""
275 };
276 let join = if priced {
277 format!("{PRICE_JOIN} ")
278 } else {
279 String::new()
280 };
281 let resource = resource_clause(kind, id);
282 format!(
283 "SELECT u.usage_date, \
284 CAST(datediff(current_date(), u.usage_date) AS INT) AS days_ago, \
285 ROUND(SUM(u.usage_quantity), 4) AS dbus{usd} \
286 FROM system.billing.usage u {join}\
287 WHERE u.usage_date >= date_sub(current_date(), 364){resource}{ws} \
288 GROUP BY 1, 2 ORDER BY 1"
289 )
290}
291
292type ResourceDay = (i64, String, f64, f64);
294
295fn parse_resource_days(table: &TableData) -> Vec<ResourceDay> {
296 table
297 .rows
298 .iter()
299 .filter_map(|row| {
300 let (date, days_ago, dbus, usd) = match row.as_slice() {
301 [d, a, v, u] => (d, a, v, Some(u)),
302 [d, a, v] => (d, a, v, None),
303 _ => return None,
304 };
305 let days_ago: i64 = days_ago.parse().ok()?;
306 let month: String = date.chars().take(7).collect();
308 Some((
309 days_ago,
310 month,
311 dbus.parse().unwrap_or(0.0),
312 usd.map_or(0.0, |u| u.parse().unwrap_or(0.0)),
313 ))
314 })
315 .collect()
316}
317
318fn aggregate_resource(days: &[ResourceDay], kind: ResourceKind, priced: bool) -> ResourceCost {
319 let sum = |from: i64, to: i64| -> (f64, f64) {
320 days.iter()
321 .filter(|(ago, _, _, _)| *ago >= from && *ago < to)
322 .fold((0.0, 0.0), |(d, u), (_, _, dbus, usd)| (d + dbus, u + usd))
323 };
324 let windows = WINDOWS
325 .iter()
326 .map(|(label, span)| {
327 let (dbus, usd) = sum(0, *span);
328 CostWindow {
329 label,
330 days: *span,
331 dbus,
332 usd,
333 prior: (span * 2 <= 365).then(|| sum(*span, span * 2)),
335 }
336 })
337 .collect();
338
339 let mut months: Vec<(String, f64, f64)> = Vec::new();
340 for (_, month, dbus, usd) in days {
341 match months.last_mut() {
342 Some((m, d, u)) if m == month => {
343 *d += dbus;
344 *u += usd;
345 }
346 _ => months.push((month.clone(), *dbus, *usd)),
347 }
348 }
349 if months.len() > 12 {
350 months.drain(..months.len() - 12);
351 }
352
353 ResourceCost {
354 kind,
355 windows,
356 months,
357 priced,
358 scoped: false,
359 }
360}
361
362pub async fn fetch_resource(
366 cli: &DatabricksCli,
367 warehouse_id: &str,
368 kind: ResourceKind,
369 id: &str,
370 workspace_id: Option<&str>,
371) -> Result<ResourceCost, String> {
372 let ws = ws_clause(workspace_id);
373 let mut data = match run_sql(cli, &resource_query(true, kind, id, &ws), warehouse_id).await {
374 Ok(table) => aggregate_resource(&parse_resource_days(&table), kind, true),
375 Err(_) => {
377 let sql = resource_query(false, kind, id, &ws);
378 let table = run_sql(cli, &sql, warehouse_id).await?;
379 aggregate_resource(&parse_resource_days(&table), kind, false)
380 }
381 };
382 data.scoped = workspace_id.is_some();
383 Ok(data)
384}
385
386pub async fn resolve_workspace_id(
390 cli: &DatabricksCli,
391 warehouse_id: &str,
392 host: &str,
393) -> Option<String> {
394 let hostname = host
395 .trim_start_matches("https://")
396 .trim_start_matches("http://")
397 .trim_end_matches('/')
398 .replace('\'', "");
399 let sql = format!(
400 "SELECT CAST(workspace_id AS STRING) \
401 FROM system.access.workspaces_latest \
402 WHERE workspace_url LIKE '%{hostname}%' LIMIT 2"
403 );
404 let table = run_sql(cli, &sql, warehouse_id).await.ok()?;
405 match table.rows.as_slice() {
406 [row] => row.first().cloned(),
407 _ => None,
408 }
409}
410
411pub async fn fetch(
417 cli: &DatabricksCli,
418 warehouse_id: &str,
419 workspace_id: Option<&str>,
420) -> Result<CostData, String> {
421 let ws = ws_clause(workspace_id);
422 let mut data = match run_sql(cli, &priced_query(&ws), warehouse_id).await {
423 Ok(table) => aggregate(&table, true),
424 Err(_) => {
426 let table = run_sql(cli, &plain_query(&ws), warehouse_id).await?;
427 aggregate(&table, false)
428 }
429 };
430 data.scoped = workspace_id.is_some();
431 if let Ok(table) = run_sql(cli, &spenders_query(data.priced, &ws), warehouse_id).await {
433 data.spenders = parse_spenders(&table);
434 if data.priced {
435 data.spenders.sort_by(|a, b| {
436 b.usd
437 .partial_cmp(&a.usd)
438 .unwrap_or(std::cmp::Ordering::Equal)
439 });
440 }
441 }
442 Ok(data)
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn day(days_ago: i64, month: &str) -> ResourceDay {
451 (days_ago, month.to_string(), 1.0, 2.0)
452 }
453
454 #[test]
455 fn windows_are_cumulative_and_exclude_their_own_prior() {
456 let days: Vec<ResourceDay> = (0..60).map(|d| day(d, "2026-07")).collect();
458 let cost = aggregate_resource(&days, ResourceKind::Job, true);
459
460 let week = &cost.windows[0];
461 assert_eq!(week.days, 7);
462 assert_eq!(week.dbus, 7.0);
463 assert_eq!(week.usd, 14.0);
464 assert_eq!(week.prior, Some((7.0, 14.0)));
466 assert_eq!(week.trend(true), Some(0.0));
467
468 assert_eq!(cost.windows[1].dbus, 30.0);
469 assert_eq!(cost.windows[2].dbus, 60.0);
470 assert_eq!(cost.windows[3].dbus, 60.0);
471 }
472
473 #[test]
474 fn year_window_has_no_prior_to_compare_with() {
475 let cost = aggregate_resource(&[day(0, "2026-07")], ResourceKind::Job, true);
477 let year = cost.windows.last().unwrap();
478 assert_eq!(year.days, 365);
479 assert_eq!(year.prior, None);
480 assert_eq!(year.trend(true), None);
481 }
482
483 #[test]
484 fn trend_is_none_when_the_prior_window_is_empty() {
485 let days = vec![day(0, "2026-07"), day(1, "2026-07"), day(2, "2026-07")];
487 let cost = aggregate_resource(&days, ResourceKind::Job, true);
488 assert_eq!(cost.windows[0].prior, Some((0.0, 0.0)));
489 assert_eq!(cost.windows[0].trend(true), None);
490 }
491
492 #[test]
493 fn trend_follows_dbus_when_prices_are_unreadable() {
494 let mut days: Vec<ResourceDay> = (0..7)
495 .map(|d| (d, "2026-07".to_string(), 2.0, 0.0))
496 .collect();
497 days.extend((7..14).map(|d| (d, "2026-07".to_string(), 1.0, 0.0)));
498 let cost = aggregate_resource(&days, ResourceKind::Job, false);
499 assert_eq!(cost.windows[0].trend(false), Some(1.0));
501 }
502
503 #[test]
504 fn months_roll_up_in_order_and_keep_the_last_twelve() {
505 let months: Vec<String> = (1..=12)
507 .map(|m| format!("2025-{m:02}"))
508 .chain((1..=2).map(|m| format!("2026-{m:02}")))
509 .collect();
510 let days: Vec<ResourceDay> = months
511 .iter()
512 .enumerate()
513 .map(|(i, m)| day((13 - i as i64) * 30, m))
514 .collect();
515 let cost = aggregate_resource(&days, ResourceKind::Pipeline, true);
516 assert_eq!(cost.months.len(), 12);
517 assert_eq!(cost.months.first().unwrap().0, "2025-03");
519 assert_eq!(cost.months.last().unwrap().0, "2026-02");
520 }
521
522 #[test]
523 fn same_month_days_are_summed_into_one_bar() {
524 let days = vec![day(1, "2026-07"), day(2, "2026-07"), day(40, "2026-06")];
525 let cost = aggregate_resource(&days, ResourceKind::Job, true);
526 assert_eq!(cost.months.len(), 2);
527 assert_eq!(cost.months[0], ("2026-07".to_string(), 2.0, 4.0));
528 }
529
530 #[test]
531 fn resource_clause_targets_the_right_metadata_field() {
532 assert!(resource_clause(ResourceKind::Job, "42").contains("usage_metadata.job_id = '42'"));
533 assert!(resource_clause(ResourceKind::Pipeline, "abc")
534 .contains("usage_metadata.dlt_pipeline_id = 'abc'"));
535 assert_eq!(
537 resource_clause(ResourceKind::Job, "4'2"),
538 " AND u.usage_metadata.job_id = '42'"
539 );
540 }
541}