1use std::collections::HashMap;
87use std::path::Path;
88use std::sync::atomic::AtomicU64;
89use std::sync::atomic::Ordering;
90
91use serde::Deserialize;
92use serde::Serialize;
93
94use super::query;
95use super::EmbeddedAnalytics;
96
97pub const EXPORT_DELAY_MS: i64 = 5 * 60_000;
102
103pub const EXPORT_TICK_MS: u64 = 30_000;
106
107pub const MAX_CATCHUP_WINDOWS: usize = 64;
111
112pub const FAILURE_RETRY_MS: i64 = 10 * 60_000;
118
119static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
128pub struct QuotaBudget {
129 pub used: u64,
131 pub limit: u64,
133 pub window_start_epoch_s: i64,
136 pub reset_epoch_s: i64,
138}
139
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
144pub struct QuotaFigures {
145 pub daily: Option<QuotaBudget>,
146 pub monthly: Option<QuotaBudget>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum WindowKind {
153 Hourly,
154 Daily,
155 Monthly,
156}
157
158impl WindowKind {
159 pub fn parse(s: &str) -> Option<Self> {
161 match s {
162 "hourly" => Some(WindowKind::Hourly),
163 "daily" => Some(WindowKind::Daily),
164 "monthly" => Some(WindowKind::Monthly),
165 _ => None,
166 }
167 }
168
169 pub fn as_str(self) -> &'static str {
170 match self {
171 WindowKind::Hourly => "hourly",
172 WindowKind::Daily => "daily",
173 WindowKind::Monthly => "monthly",
174 }
175 }
176
177 pub fn window_of(self, ts_ms: i64) -> (i64, i64) {
180 match self {
181 WindowKind::Hourly => {
182 let g = 3_600_000_i64;
183 let start = ts_ms.div_euclid(g) * g;
184 (start, start + g)
185 }
186 WindowKind::Daily => {
187 let g = 86_400_000_i64;
188 let start = ts_ms.div_euclid(g) * g;
189 (start, start + g)
190 }
191 WindowKind::Monthly => month_window_ms(ts_ms),
192 }
193 }
194
195 pub fn gran(self) -> usize {
201 match self {
202 WindowKind::Hourly | WindowKind::Daily => 2,
203 WindowKind::Monthly => 3,
204 }
205 }
206
207 fn file_stem(self, window_start_ms: i64) -> String {
211 let (y, m, d) = civil_from_days(window_start_ms.div_euclid(86_400_000));
212 let stamp = match self {
213 WindowKind::Hourly => {
214 let hour = window_start_ms.rem_euclid(86_400_000) / 3_600_000;
215 format!("{y:04}-{m:02}-{d:02}T{hour:02}Z")
216 }
217 WindowKind::Daily => format!("{y:04}-{m:02}-{d:02}"),
218 WindowKind::Monthly => format!("{y:04}-{m:02}"),
219 };
220 format!("dwara-usage-{}-{stamp}", self.as_str())
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227pub enum ExportFormat {
228 Csv,
229 Json,
230 ParquetCsv,
232}
233
234impl ExportFormat {
235 pub fn as_str(self) -> &'static str {
236 match self {
237 ExportFormat::Csv => "csv",
238 ExportFormat::Json => "json",
239 ExportFormat::ParquetCsv => "parquet_csv",
240 }
241 }
242
243 fn extension(self) -> &'static str {
244 match self {
245 ExportFormat::Csv => "csv",
246 ExportFormat::Json => "json",
247 ExportFormat::ParquetCsv => "parquet.csv",
248 }
249 }
250}
251
252macro_rules! impl_from_config {
257 ($ty:ident, $cfg:ident, $($v:ident),+ $(,)?) => {
258 impl From<crate::config::$cfg> for $ty {
259 fn from(c: crate::config::$cfg) -> Self {
260 match c {
261 $(crate::config::$cfg::$v => $ty::$v,)+
262 }
263 }
264 }
265 };
266}
267
268impl_from_config!(WindowKind, AnalyticsExportWindow, Hourly, Daily, Monthly);
269impl_from_config!(ExportFormat, AnalyticsExportFormat, Csv, Json, ParquetCsv);
270
271#[derive(Debug, Serialize)]
274pub struct StatementRow {
275 pub consumer: String,
276 pub requests: i64,
277 pub errors: i64,
278 pub error_rate: f64,
279 pub rate_limited: i64,
280 pub shed: i64,
281 pub avg_ms: f64,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub quota_daily: Option<QuotaBudget>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub quota_monthly: Option<QuotaBudget>,
286 pub prompt_tokens: i64,
291 pub completion_tokens: i64,
292 pub total_tokens: i64,
293 pub cost_micros: i64,
295}
296
297#[derive(Debug, Serialize)]
301pub struct SpendByModel {
302 pub model: String,
303 pub prompt_tokens: i64,
304 pub completion_tokens: i64,
305 pub total_tokens: i64,
306 pub cost_micros: i64,
307 pub request_count: i64,
308}
309
310#[derive(Debug, Serialize)]
312struct UsageStatement {
313 kind: &'static str,
314 window: &'static str,
315 from_ms: i64,
316 to_ms: i64,
317 generated_at_ms: i64,
318 partial: bool,
319 windows_nonempty: i64,
322 totals: Option<query::QueryRow>,
323 consumers: Vec<StatementRow>,
324 #[serde(skip_serializing_if = "Vec::is_empty")]
327 spend_by_model: Vec<SpendByModel>,
328}
329
330#[derive(Debug, Serialize)]
333pub struct ExportRun {
334 pub kind: String,
335 pub window_start_ms: i64,
336 pub window_end_ms: i64,
337 pub status: String,
339 pub partial: bool,
340 pub formats: Vec<String>,
341 pub directory: String,
342 pub consumers: usize,
343 pub requests: i64,
344 pub windows_nonempty: i64,
345 pub error: String,
346 pub generated_at_ms: i64,
347}
348
349pub fn effective_window(cfg: &crate::config::AnalyticsExports) -> WindowKind {
352 cfg.window
353 .map(WindowKind::from)
354 .unwrap_or(WindowKind::Daily)
355}
356
357pub fn effective_formats(cfg: &crate::config::AnalyticsExports) -> Vec<ExportFormat> {
361 if cfg.formats.is_empty() {
362 vec![ExportFormat::Csv, ExportFormat::Json]
363 } else {
364 cfg.formats.iter().map(|f| ExportFormat::from(*f)).collect()
365 }
366}
367
368pub fn run_export(
374 store: &EmbeddedAnalytics,
375 directory: &str,
376 window: WindowKind,
377 window_start_ms: i64,
378 formats: &[ExportFormat],
379 quota_at: &dyn Fn(i64, i64) -> HashMap<String, QuotaFigures>,
380 now_ms: i64,
381) -> ExportRun {
382 let (from_ms, to_ms) = window.window_of(window_start_ms);
383 let gran = window.gran();
384 store.maintain();
387 let retention = store.retention_ms[gran + 1];
388 let partial = from_ms < now_ms.saturating_sub(retention);
389
390 let mut run = ExportRun {
391 kind: window.as_str().to_string(),
392 window_start_ms: from_ms,
393 window_end_ms: to_ms,
394 status: "ok".to_string(),
395 partial,
396 formats: formats.iter().map(|f| f.as_str().to_string()).collect(),
397 directory: directory.to_string(),
398 consumers: 0,
399 requests: 0,
400 windows_nonempty: 0,
401 error: String::new(),
402 generated_at_ms: now_ms,
403 };
404
405 let read = store.query(|c| {
409 let grouped = query::structured(
410 c,
411 &query::StructuredQuery {
412 from_ms,
413 to_ms,
414 gran,
415 group_by: vec!["consumer".to_string()],
416 dim_group_by: None,
417 filters: query::FiltersBody::default(),
418 dim_filters: Default::default(),
419 limit: Some(10_000),
420 },
421 )?;
422 let totals = query::structured(
423 c,
424 &query::StructuredQuery {
425 from_ms,
426 to_ms,
427 gran,
428 group_by: Vec::new(),
429 dim_group_by: None,
430 filters: query::FiltersBody::default(),
431 dim_filters: Default::default(),
432 limit: Some(1),
433 },
434 )?;
435 let nonempty: i64 = c.query_row(
436 "SELECT COUNT(DISTINCT window_start) FROM rollup_fixed
437 WHERE gran = ?1 AND window_start >= ?2 AND window_start < ?3",
438 rusqlite::params![gran as i64, from_ms, to_ms],
439 |r| r.get(0),
440 )?;
441 let spend_by_consumer = query::spend_summary(
446 c,
447 &query::SpendQuery {
448 from_ms,
449 to_ms,
450 group_by: vec!["consumer".to_string()],
451 limit: Some(10_000),
452 },
453 )
454 .unwrap_or_default();
455 let spend_by_model = query::spend_summary(
456 c,
457 &query::SpendQuery {
458 from_ms,
459 to_ms,
460 group_by: vec!["model".to_string()],
461 limit: Some(10_000),
462 },
463 )
464 .unwrap_or_default();
465 Ok((grouped, totals, nonempty, spend_by_consumer, spend_by_model))
466 });
467 let (grouped, totals, nonempty, spend_by_consumer, spend_by_model) = match read {
468 Ok(v) => v,
469 Err(e) => {
470 return record_run(
471 store,
472 failed_run(run, &format!("analytics query failed: {e}")),
473 );
474 }
475 };
476
477 let mut spend_map: std::collections::HashMap<String, &query::SpendRow> =
479 std::collections::HashMap::new();
480 for s in &spend_by_consumer {
481 if let Some(name) = s.key.first() {
482 spend_map.insert(name.clone(), s);
483 }
484 }
485
486 let quotas = quota_at(from_ms / 1000, to_ms / 1000);
487 let mut rows = Vec::with_capacity(grouped.len());
488 for r in &grouped {
489 let q = quotas.get(&r.key[0]).copied().unwrap_or_default();
490 let spend = spend_map.get(&r.key[0]);
491 rows.push(StatementRow {
492 consumer: r.key[0].clone(),
493 requests: r.requests,
494 errors: r.errors,
495 error_rate: r.error_rate,
496 rate_limited: r.rate_limited,
497 shed: r.shed,
498 avg_ms: r.avg_ms,
499 quota_daily: q.daily,
500 quota_monthly: q.monthly,
501 prompt_tokens: spend.map(|s| s.prompt_tokens).unwrap_or(0),
502 completion_tokens: spend.map(|s| s.completion_tokens).unwrap_or(0),
503 total_tokens: spend.map(|s| s.total_tokens).unwrap_or(0),
504 cost_micros: spend.map(|s| s.cost_micros).unwrap_or(0),
505 });
506 }
507 run.consumers = rows.len();
508 run.requests = totals.first().map(|t| t.requests).unwrap_or(0);
509 run.windows_nonempty = nonempty;
510
511 let spend_by_model = spend_by_model
512 .iter()
513 .map(|s| SpendByModel {
514 model: s.key.first().cloned().unwrap_or_default(),
515 prompt_tokens: s.prompt_tokens,
516 completion_tokens: s.completion_tokens,
517 total_tokens: s.total_tokens,
518 cost_micros: s.cost_micros,
519 request_count: s.request_count,
520 })
521 .collect();
522
523 let statement = UsageStatement {
524 kind: "usage_statement",
525 window: window.as_str(),
526 from_ms,
527 to_ms,
528 generated_at_ms: now_ms,
529 partial,
530 windows_nonempty: nonempty,
531 totals: totals.into_iter().next(),
532 consumers: rows,
533 spend_by_model,
534 };
535
536 let dir = Path::new(directory);
539 if let Err(e) = std::fs::create_dir_all(dir) {
540 return record_run(
541 store,
542 failed_run(run, &format!("export directory unusable: {e}")),
543 );
544 }
545 for f in formats {
546 let filename = format!("{}.{}", window.file_stem(from_ms), f.extension());
547 let bytes = match f {
548 ExportFormat::Json => match serde_json::to_vec_pretty(&statement) {
549 Ok(b) => b,
550 Err(e) => {
551 return record_run(store, failed_run(run, &format!("JSON render failed: {e}")))
552 }
553 },
554 ExportFormat::Csv => render_csv(&statement),
555 ExportFormat::ParquetCsv => render_parquet_csv(&statement),
556 };
557 if let Err(e) = write_atomic(dir, &filename, &bytes) {
558 return record_run(
559 store,
560 failed_run(run, &format!("write {filename} failed: {e}")),
561 );
562 }
563 }
564 record_run(store, run)
565}
566
567pub fn run_due(
575 store: &EmbeddedAnalytics,
576 cfg: &crate::config::AnalyticsExports,
577 quota_at: &dyn Fn(i64, i64) -> HashMap<String, QuotaFigures>,
578 now_ms: i64,
579) -> Vec<ExportRun> {
580 let window = effective_window(cfg);
581 let formats = effective_formats(cfg);
582 let gran = window.gran();
583 let mut start = last_closed_window(window, now_ms.saturating_sub(EXPORT_DELAY_MS));
588 let horizon = now_ms.saturating_sub(store.retention_ms[gran + 1]);
589
590 let done: HashMap<i64, (String, i64)> = store
594 .query(|c| {
595 let mut stmt = c.prepare(
596 "SELECT window_start_ms, status, generated_at_ms FROM export_runs
597 WHERE kind = ?1",
598 )?;
599 let rows = stmt
600 .query_map(rusqlite::params![window.as_str()], |r| {
601 Ok((
602 r.get::<_, i64>(0)?,
603 r.get::<_, String>(1)?,
604 r.get::<_, i64>(2)?,
605 ))
606 })?
607 .map(|r| r.map(|(w, s, g)| (w, (s, g))))
608 .collect::<Result<HashMap<_, _>, _>>()?;
609 Ok(rows)
610 })
611 .unwrap_or_default();
612
613 let mut due = Vec::new();
614 while due.len() < MAX_CATCHUP_WINDOWS {
615 if start < horizon || start < 0 {
616 break;
617 }
618 if let Some((status, at_ms)) = done.get(&start) {
619 let ok = status == "ok";
620 let failed_recently = status == "failed" && now_ms - at_ms < FAILURE_RETRY_MS;
621 if ok || failed_recently {
622 break;
623 }
624 }
625 due.push(start);
626 let (prev, _) = window.window_of(start - 1);
627 start = prev;
628 }
629 due.reverse();
630 due.iter()
631 .map(|&s| run_export(store, &cfg.directory, window, s, &formats, quota_at, now_ms))
632 .collect()
633}
634
635pub fn last_closed_window(window: WindowKind, now_ms: i64) -> i64 {
641 let (start, _) = window.window_of(now_ms);
642 let (prev, _) = window.window_of(start - 1);
643 prev
644}
645
646pub fn list_runs(store: &EmbeddedAnalytics, limit: usize) -> rusqlite::Result<Vec<ExportRun>> {
648 store.query(|c| {
649 let mut stmt = c.prepare(
650 "SELECT kind, window_start_ms, window_end_ms, status, partial,
651 formats, directory, consumers, requests, windows_nonempty,
652 error, generated_at_ms
653 FROM export_runs
654 ORDER BY generated_at_ms DESC, kind ASC
655 LIMIT ?1",
656 )?;
657 let rows = stmt
658 .query_map(rusqlite::params![limit as i64], |r| {
659 Ok(ExportRun {
660 kind: r.get(0)?,
661 window_start_ms: r.get(1)?,
662 window_end_ms: r.get(2)?,
663 status: r.get(3)?,
664 partial: r.get::<_, i64>(4)? != 0,
665 formats: r
666 .get::<_, String>(5)?
667 .split(',')
668 .map(str::to_string)
669 .collect(),
670 directory: r.get(6)?,
671 consumers: r.get::<_, i64>(7)? as usize,
672 requests: r.get(8)?,
673 windows_nonempty: r.get(9)?,
674 error: r.get(10)?,
675 generated_at_ms: r.get(11)?,
676 })
677 })?
678 .collect::<Result<Vec<_>, _>>()?;
679 Ok(rows)
680 })
681}
682
683#[derive(Debug, Default, Deserialize)]
686pub struct ManualRunBody {
687 pub window: Option<String>,
688 pub window_start_ms: Option<i64>,
689}
690
691fn failed_run(mut run: ExportRun, error: &str) -> ExportRun {
692 run.status = "failed".to_string();
693 run.error = error.to_string();
694 run
695}
696
697fn record_run(store: &EmbeddedAnalytics, run: ExportRun) -> ExportRun {
701 let row = (
702 run.kind.as_str(),
703 run.window_start_ms,
704 run.window_end_ms,
705 run.status.as_str(),
706 i64::from(run.partial),
707 run.formats.join(","),
708 run.directory.as_str(),
709 run.consumers as i64,
710 run.requests,
711 run.windows_nonempty,
712 run.error.as_str(),
713 run.generated_at_ms,
714 );
715 if let Err(e) = store.query(|c| {
716 c.execute(
717 "INSERT INTO export_runs (
718 kind, window_start_ms, window_end_ms, status, partial,
719 formats, directory, consumers, requests, windows_nonempty,
720 error, generated_at_ms
721 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
722 ON CONFLICT(kind, window_start_ms) DO UPDATE SET
723 window_end_ms = excluded.window_end_ms,
724 status = excluded.status,
725 partial = excluded.partial,
726 formats = excluded.formats,
727 directory = excluded.directory,
728 consumers = excluded.consumers,
729 requests = excluded.requests,
730 windows_nonempty = excluded.windows_nonempty,
731 error = excluded.error,
732 generated_at_ms = excluded.generated_at_ms",
733 rusqlite::params![
734 row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10,
735 row.11
736 ],
737 )?;
738 Ok(())
739 }) {
740 tracing::warn!(
741 code = "analytics_export_record_failed",
742 "export run record write failed: {e}"
743 );
744 }
745 if run.status == "failed" {
746 tracing::warn!(
747 code = "analytics_export_failed",
748 window = %run.kind,
749 from_ms = run.window_start_ms,
750 "{}",
751 run.error
752 );
753 } else {
754 tracing::info!(
755 code = "analytics_export_ok",
756 window = %run.kind,
757 from_ms = run.window_start_ms,
758 consumers = run.consumers,
759 requests = run.requests,
760 partial = run.partial,
761 "usage statement exported"
762 );
763 }
764 run
765}
766
767fn render_csv(s: &UsageStatement) -> Vec<u8> {
773 let mut out = String::new();
774 let mut header = Vec::new();
775 for h in [
776 "consumer",
777 "requests",
778 "errors",
779 "error_rate",
780 "rate_limited",
781 "shed",
782 "avg_ms",
783 "quota_daily_used",
784 "quota_daily_limit",
785 "quota_monthly_used",
786 "quota_monthly_limit",
787 "prompt_tokens",
788 "completion_tokens",
789 "total_tokens",
790 "cost_micros",
791 ] {
792 header.push(h.to_string());
793 }
794 push_csv_row(&mut out, &header);
795 for c in &s.consumers {
796 let mut fields = vec![
797 c.consumer.clone(),
798 c.requests.to_string(),
799 c.errors.to_string(),
800 c.error_rate.to_string(),
801 c.rate_limited.to_string(),
802 c.shed.to_string(),
803 c.avg_ms.to_string(),
804 ];
805 for q in [c.quota_daily, c.quota_monthly] {
806 match q {
807 Some(b) => {
808 fields.push(b.used.to_string());
809 fields.push(b.limit.to_string());
810 }
811 None => {
812 fields.push(String::new());
813 fields.push(String::new());
814 }
815 }
816 }
817 fields.push(c.prompt_tokens.to_string());
819 fields.push(c.completion_tokens.to_string());
820 fields.push(c.total_tokens.to_string());
821 fields.push(c.cost_micros.to_string());
822 push_csv_row(&mut out, &fields);
823 }
824 out.into_bytes()
825}
826
827fn render_parquet_csv(s: &UsageStatement) -> Vec<u8> {
834 let mut out = String::new();
835 let schema = serde_json::json!({
837 "format": "parquet-csv",
838 "version": 1,
839 "columns": [
840 {"name": "consumer", "type": "string"},
841 {"name": "requests", "type": "int64"},
842 {"name": "errors", "type": "int64"},
843 {"name": "error_rate", "type": "double"},
844 {"name": "rate_limited", "type": "int64"},
845 {"name": "shed", "type": "int64"},
846 {"name": "avg_ms", "type": "double"},
847 {"name": "quota_daily_used", "type": "int64"},
848 {"name": "quota_daily_limit", "type": "int64"},
849 {"name": "quota_monthly_used", "type": "int64"},
850 {"name": "quota_monthly_limit", "type": "int64"},
851 {"name": "prompt_tokens", "type": "int64"},
852 {"name": "completion_tokens", "type": "int64"},
853 {"name": "total_tokens", "type": "int64"},
854 {"name": "cost_micros", "type": "int64"},
855 ]
856 });
857 out.push_str(&format!("#{}\n", schema));
858 out.push_str(&String::from_utf8(render_csv(s)).unwrap_or_default());
860 out.into_bytes()
861}
862
863fn push_csv_row(out: &mut String, fields: &[String]) {
865 for (i, f) in fields.iter().enumerate() {
866 if i > 0 {
867 out.push(',');
868 }
869 if f.bytes()
870 .any(|b| b == b',' || b == b'"' || b == b'\r' || b == b'\n')
871 {
872 out.push('"');
873 for ch in f.chars() {
874 if ch == '"' {
875 out.push('"');
876 }
877 out.push(ch);
878 }
879 out.push('"');
880 } else {
881 out.push_str(f);
882 }
883 }
884 out.push_str("\r\n");
885}
886
887fn write_atomic(dir: &Path, filename: &str, bytes: &[u8]) -> std::io::Result<()> {
895 let tmp = dir.join(format!(
896 ".{filename}.{}.tmp",
897 TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
898 ));
899 let mut file = std::fs::File::create(&tmp)?;
900 std::io::Write::write_all(&mut file, bytes)?;
901 file.sync_all()?;
902 drop(file);
903 std::fs::rename(&tmp, dir.join(filename))?;
904 std::fs::File::open(dir)?.sync_all()
905}
906
907fn month_window_ms(ts_ms: i64) -> (i64, i64) {
913 const MS_PER_DAY: i64 = 86_400_000;
914 let days = ts_ms.div_euclid(MS_PER_DAY);
915 let (y, m, _) = civil_from_days(days);
916 let start_days = days_from_civil(y, m, 1);
917 let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
918 let reset_days = days_from_civil(ny, nm, 1);
919 (start_days * MS_PER_DAY, reset_days * MS_PER_DAY)
920}
921
922fn civil_from_days(z: i64) -> (i64, u32, u32) {
926 let z = z + 719_468;
927 let era = z.div_euclid(146_097);
928 let doe = z.rem_euclid(146_097);
929 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
930 let y = yoe + era * 400;
931 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
932 let mp = (5 * doy + 2) / 153;
933 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
934 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
935 (if m <= 2 { y + 1 } else { y }, m, d)
936}
937
938fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
940 let y = if m <= 2 { y - 1 } else { y };
941 let era = y.div_euclid(400);
942 let yoe = y.rem_euclid(400);
943 let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
944 let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
945 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
946 era * 146_097 + doe - 719_468
947}
948
949#[cfg(test)]
955mod tests {
956 use super::*;
957
958 fn parse_csv(text: &str) -> Vec<Vec<String>> {
961 let mut rows = Vec::new();
962 let mut row = Vec::new();
963 let mut field = String::new();
964 let mut in_quotes = false;
965 let mut chars = text.chars().peekable();
966 while let Some(c) = chars.next() {
967 if in_quotes {
968 if c == '"' {
969 if chars.peek() == Some(&'"') {
970 chars.next();
971 field.push('"');
972 } else {
973 in_quotes = false;
974 }
975 } else {
976 field.push(c);
977 }
978 } else {
979 match c {
980 '"' => in_quotes = true,
981 ',' => {
982 row.push(std::mem::take(&mut field));
983 }
984 '\r' => {}
985 '\n' => {
986 row.push(std::mem::take(&mut field));
987 rows.push(std::mem::take(&mut row));
988 }
989 _ => field.push(c),
990 }
991 }
992 }
993 if !field.is_empty() || !row.is_empty() {
994 row.push(field);
995 rows.push(row);
996 }
997 rows
998 }
999
1000 #[test]
1001 fn csv_writer_escapes_rfc4180_and_roundtrips() {
1002 let mut rows = vec![vec![
1003 "consumer".to_string(),
1004 "plain".to_string(),
1005 "a,b".to_string(),
1006 "has\"quote".to_string(),
1007 "line\nbreak".to_string(),
1008 "cr\r lf".to_string(),
1009 "".to_string(),
1010 ]];
1011 let hostile = vec!["x\",y".to_string(), ",,\r\n\"".to_string()];
1012 rows.push(hostile.clone());
1013 let mut text = String::new();
1014 for r in &rows {
1015 push_csv_row(&mut text, r);
1016 }
1017 assert!(text.contains("\"a,b\""));
1018 assert!(text.contains("\"has\"\"quote\""));
1019 assert!(text.ends_with("\r\n"));
1020 let parsed = parse_csv(&text);
1021 assert_eq!(parsed.len(), rows.len());
1022 for (got, want) in parsed.iter().zip(rows.iter()) {
1023 assert_eq!(got, want);
1024 }
1025 }
1026
1027 #[test]
1028 fn month_windows_are_calendar_correct() {
1029 assert_eq!(month_window_ms(0), (0, 31 * 86_400_000));
1031 let feb = 31 * 86_400_000;
1033 assert_eq!(month_window_ms(feb), (feb, 59 * 86_400_000));
1034 let leap_feb_ms = days_from_civil(1972, 2, 1) * 86_400_000;
1036 let mar72 = days_from_civil(1972, 3, 1) * 86_400_000;
1037 assert_eq!(month_window_ms(leap_feb_ms), (leap_feb_ms, mar72));
1038 assert_eq!(mar72 - leap_feb_ms, 29 * 86_400_000);
1039 let dec26 = days_from_civil(2026, 12, 1) * 86_400_000;
1041 let jan27 = days_from_civil(2027, 1, 1) * 86_400_000;
1042 assert_eq!(month_window_ms(dec26 + 1000), (dec26, jan27));
1043 for (y, m, d) in [
1045 (1970, 1, 1),
1046 (2000, 2, 29),
1047 (2026, 8, 29),
1048 (2100, 3, 1),
1049 (2400, 12, 31),
1050 ] {
1051 assert_eq!(civil_from_days(days_from_civil(y, m, d)), (y, m, d));
1052 }
1053 }
1054
1055 #[test]
1056 fn file_stems_are_deterministic_and_distinct() {
1057 let day = 1_787_961_600_000; assert_eq!(
1059 WindowKind::Daily.file_stem(day),
1060 "dwara-usage-daily-2026-08-29"
1061 );
1062 assert_eq!(
1063 WindowKind::Hourly.file_stem(day + 13 * 3_600_000),
1064 "dwara-usage-hourly-2026-08-29T13Z"
1065 );
1066 assert_eq!(
1067 WindowKind::Monthly.file_stem(day),
1068 "dwara-usage-monthly-2026-08"
1069 );
1070 }
1071}