1use chrono::{DateTime, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8
9pub const USAGE_EVENT_SCHEMA_VERSION: &str = "usage_event.v1";
10pub const USAGE_SUMMARY_SCHEMA_VERSION: &str = "usage_summary.v1";
11pub const REPORTED_USAGE_SUMMARY_INPUT_SCHEMA_VERSION: &str = "reported_usage_summary_input.v1";
12pub const SOURCE_LOCATION_SCHEMA_VERSION: &str = "source_location.v1";
13pub const PROVIDER_ACCOUNT_SCHEMA_VERSION: &str = "provider_account.v1";
14pub const SOURCE_ACCOUNT_ASSIGNMENT_SCHEMA_VERSION: &str = "source_account_assignment.v1";
15pub const SUBSCRIPTION_SCHEMA_VERSION: &str = "subscription.v1";
16pub const DAILY_ROLLUP_SCHEMA_VERSION: &str = "daily_rollup.v1";
17pub const SYNC_BATCH_SCHEMA_VERSION: &str = "sync_batch.v1";
18pub const SYNC_ACK_SCHEMA_VERSION: &str = "sync_ack.v1";
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
21#[serde(transparent)]
22pub struct SourceId(pub String);
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
25#[serde(transparent)]
26pub struct ProviderAccountId(pub String);
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
29#[serde(transparent)]
30pub struct SubscriptionId(pub String);
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
33#[serde(transparent)]
34pub struct SourceAccountAssignmentId(pub String);
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
37#[serde(transparent)]
38pub struct EventId(pub String);
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
41#[serde(transparent)]
42pub struct SummaryId(pub String);
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum SourceKind {
47 LocalAdapter,
48 LocalSummary,
49 LocalApi,
50 ProviderApi,
51 CliProbe,
52 SdkInstrumented,
53 ExternalReport,
54 Manual,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "snake_case")]
59pub enum LocationOrigin {
60 Default,
61 Configured,
62 Env,
63 Discovered,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum Confidence {
69 Low,
70 Medium,
71 High,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75#[serde(rename_all = "snake_case")]
76pub enum IdentitySource {
77 ProviderAuth,
78 ProviderApi,
79 CliProbe,
80 SourceConfig,
81 UserConfigured,
82 ManualHint,
83 LocalAuth,
84 CookieOauth,
85 Unresolved,
86 Unknown,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90#[serde(rename_all = "snake_case")]
91pub enum BillingPeriod {
92 Monthly,
93 Annual,
94 Custom,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98#[serde(rename_all = "snake_case")]
99pub enum SubscriptionStatus {
100 Active,
101 Paused,
102 Cancelled,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
106#[serde(rename_all = "snake_case")]
107pub enum PrivacyMode {
108 MetadataOnly,
109 TitlesLabels,
110 EnrichedSummaries,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
114#[serde(rename_all = "snake_case")]
115pub enum SourceVerificationMode {
116 #[default]
117 Auto,
118 ManualOnly,
119 Disabled,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct SourceLocation {
124 pub schema_version: String,
125 pub source_id: SourceId,
126 pub provider: String,
127 pub source_kind: SourceKind,
128 pub location_origin: LocationOrigin,
129 pub adapter_id: Option<String>,
130 pub adapter_version: Option<String>,
131 pub path_hash: Option<String>,
132 pub path_label: Option<String>,
133 pub enabled: bool,
134 #[serde(default)]
135 pub verification_mode: SourceVerificationMode,
136 #[serde(default)]
137 pub verified_state_hash: Option<String>,
138 pub created_at: DateTime<Utc>,
139 pub updated_at: DateTime<Utc>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
143pub struct ProviderAccount {
144 pub schema_version: String,
145 pub provider_account_id: ProviderAccountId,
146 pub provider: String,
147 pub identity_source: IdentitySource,
148 pub provider_user_id: Option<String>,
149 pub email: Option<String>,
150 pub provider_user_id_hash: Option<String>,
151 pub email_hash: Option<String>,
152 pub org_id_hash: Option<String>,
153 pub account_label: Option<String>,
154 pub plan_name: Option<String>,
155 pub confidence: Confidence,
156 pub verified_at: Option<DateTime<Utc>>,
157 pub created_at: DateTime<Utc>,
158 pub updated_at: DateTime<Utc>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct SourceAccountAssignment {
163 pub schema_version: String,
164 pub assignment_id: SourceAccountAssignmentId,
165 pub source_id: SourceId,
166 pub provider: String,
167 pub provider_account_id: ProviderAccountId,
168 pub started_at: DateTime<Utc>,
169 pub ended_at: Option<DateTime<Utc>>,
170 #[serde(default = "default_identity_source_unknown")]
171 pub record_source: IdentitySource,
172 pub verified_at: Option<DateTime<Utc>>,
173 pub created_at: DateTime<Utc>,
174 pub updated_at: DateTime<Utc>,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
178pub struct Subscription {
179 pub schema_version: String,
180 pub subscription_id: SubscriptionId,
181 pub provider: String,
182 pub provider_account_id: ProviderAccountId,
183 pub plan_name: String,
184 pub price: i64, pub currency: String,
186 pub billing_period: BillingPeriod,
187 pub paid_at: Option<DateTime<Utc>>,
188 pub renewal_day: Option<u8>,
189 pub started_at: DateTime<Utc>,
190 pub ended_at: Option<DateTime<Utc>>,
191 pub current_period_ends_at: Option<DateTime<Utc>>,
192 pub status: SubscriptionStatus,
193 #[serde(default = "default_identity_source_unknown")]
194 pub record_source: IdentitySource,
195 pub verified_at: Option<DateTime<Utc>>,
196 pub notes: Option<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
200pub struct VerifiedSourceState {
201 pub provider_user_id: Option<String>,
202 pub email: Option<String>,
203 pub account_label: Option<String>,
204 pub plan_name: Option<String>,
205 pub authenticated_at: Option<DateTime<Utc>>,
206 pub verified_at: Option<DateTime<Utc>>,
207 pub subscription: Option<VerifiedSubscriptionState>,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
211pub struct VerifiedSubscriptionState {
212 pub plan_name: String,
213 pub price: i64, pub currency: String,
215 pub billing_period: BillingPeriod,
216 pub paid_at: Option<DateTime<Utc>>,
217 pub started_at: DateTime<Utc>,
218 pub ended_at: Option<DateTime<Utc>>,
219 pub current_period_ends_at: Option<DateTime<Utc>>,
220 pub status: SubscriptionStatus,
221 pub verified_at: Option<DateTime<Utc>>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
225pub struct EventSource {
226 pub adapter_id: String,
227 pub adapter_version: String,
228 pub source_kind: SourceKind,
229 pub location_origin: Option<LocationOrigin>,
230 pub source_type: String,
231 pub source_path_hash: Option<String>,
232 pub source_record_id: Option<String>,
233 pub parse_confidence: Confidence,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
237pub struct SessionInfo {
238 pub session_id: String,
239 pub local_session_id_hash: Option<String>,
240 pub title: Option<String>,
241 pub started_at: DateTime<Utc>,
242 pub ended_at: Option<DateTime<Utc>>,
243 pub duration_seconds: Option<u64>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
247pub struct ModelInfo {
248 pub name: Option<String>,
249 pub normalized_name: Option<String>,
250 pub provider_model_id: Option<String>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
254pub struct UsageCounts {
255 pub input_tokens: Option<u64>,
256 pub output_tokens: Option<u64>,
257 pub cache_creation_tokens: Option<u64>,
258 pub cache_read_tokens: Option<u64>,
259 pub reasoning_tokens: Option<u64>,
260 pub total_tokens: Option<u64>,
261 pub requests: Option<u64>,
262 pub local_prompt_eval_tokens: Option<u64>,
263 pub local_eval_tokens: Option<u64>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
267pub struct RuntimeInfo {
268 pub runtime_name: Option<String>,
269 pub host_id: Option<String>,
270 pub latency_ms: Option<u64>,
272 pub latency_source: Option<LatencySource>,
274 pub time_to_first_token_ms: Option<u64>,
276 pub prompt_eval_duration_ms: Option<u64>,
277 pub eval_duration_ms: Option<u64>,
278 pub total_messages: Option<u64>,
279 pub user_messages: Option<u64>,
280 pub assistant_messages: Option<u64>,
281 pub developer_messages: Option<u64>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
285#[serde(rename_all = "snake_case")]
286pub enum LatencySource {
287 Explicit,
288 Inferred,
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
292pub struct MetricStats {
293 pub samples: u64,
294 pub avg: Option<f64>,
295 pub min: Option<f64>,
296 pub max: Option<f64>,
297 pub p50: Option<f64>,
298 pub p95: Option<f64>,
299 pub sum: Option<f64>,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
303pub struct SummaryMetrics {
304 pub active_seconds: Option<f64>,
305 pub tracked_requests: Option<u64>,
306 pub tracked_output_tokens: Option<u64>,
307 pub tracked_reasoning_tokens: Option<u64>,
308 pub latency_ms: Option<MetricStats>,
310 pub time_to_first_token_ms: Option<MetricStats>,
311 pub generated_tps: Option<MetricStats>,
313 pub visible_tps: Option<MetricStats>,
315 pub overall_generated_tps: Option<f64>,
317 pub overall_visible_tps: Option<f64>,
319 pub cache_hit_ratio: Option<MetricStats>,
320 pub reasoning_share: Option<MetricStats>,
321 pub total_messages: Option<u64>,
322 pub user_messages: Option<u64>,
323 pub assistant_messages: Option<u64>,
324 pub developer_messages: Option<u64>,
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
328pub struct CostInfo {
329 pub currency: String,
330 pub estimated_api_equivalent_usd: Option<i64>, pub provider_reported_usd: Option<i64>, pub pricing_source: Option<String>,
333 pub pricing_version: Option<String>,
334 pub confidence: Confidence,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
338pub struct SummaryModelUsage {
339 pub model: ModelInfo,
340 pub usage: UsageCounts,
341 pub cost: CostInfo,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
345pub struct ParseEvidence {
346 pub event_key_version: String,
347 pub source_file_path_hash: Option<String>,
348 pub source_line_number: Option<u64>,
349 pub source_record_id: Option<String>,
350 pub model_inferred: bool,
351 pub timestamp_inferred: bool,
352 pub account_identity_source: IdentitySource,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
356pub struct ProjectInfo {
357 pub project_id: String,
358 pub project_label: Option<String>,
359 pub repo_remote_hash: Option<String>,
360 pub repo_label: Option<String>,
361 pub branch_hash: Option<String>,
362 pub branch_label: Option<String>,
363 pub path_hash: Option<String>,
364 pub path_label: Option<String>,
365}
366
367#[must_use]
368pub fn project_has_stable_identity(project: &ProjectInfo) -> bool {
369 project
370 .repo_remote_hash
371 .as_deref()
372 .is_some_and(|value| !value.trim().is_empty())
373 || project
374 .path_hash
375 .as_deref()
376 .is_some_and(|value| !value.trim().is_empty())
377}
378
379#[must_use]
380pub fn project_has_remote_identity(project: &ProjectInfo) -> bool {
381 project
382 .repo_remote_hash
383 .as_deref()
384 .is_some_and(|value| !value.trim().is_empty())
385}
386
387#[must_use]
388pub fn project_contains_file_paths(project: Option<&ProjectInfo>) -> bool {
389 project
390 .and_then(|project| project.path_label.as_deref())
391 .is_some_and(|value| !value.trim().is_empty())
392}
393
394#[must_use]
395pub fn project_bucket_key(project: Option<&ProjectInfo>) -> String {
396 let Some(project) = project else {
397 return "none".to_string();
398 };
399 if !project_has_stable_identity(project) {
400 return "none".to_string();
401 }
402 if project.path_hash.is_some()
403 || project.repo_remote_hash.is_some()
404 || project.branch_hash.is_some()
405 {
406 return format!(
407 "repo:{}|path:{}|branch:{}",
408 project.repo_remote_hash.as_deref().unwrap_or("none"),
409 project.path_hash.as_deref().unwrap_or("none"),
410 project.branch_hash.as_deref().unwrap_or("none")
411 );
412 }
413 project.project_id.clone()
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
417pub struct GitInfo {
418 pub nearby_commit_hashes: Vec<String>,
419 pub nearby_commit_messages: Vec<String>,
420 pub correlation_confidence: Option<Confidence>,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
424pub struct PrivacyInfo {
425 pub mode: PrivacyMode,
426 pub contains_prompt_text: bool,
427 pub contains_response_text: bool,
428 pub contains_file_paths: bool,
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
432pub struct UsageEvent {
433 pub schema_version: String,
434 pub event_id: EventId,
435 pub device_id: String,
436 pub provider: String,
437 pub source_id: SourceId,
438 pub provider_account_id: Option<ProviderAccountId>,
439 pub subscription_id: Option<SubscriptionId>,
440 pub source: EventSource,
441 pub session: SessionInfo,
442 pub model: Option<ModelInfo>,
443 pub usage: UsageCounts,
444 pub runtime: Option<RuntimeInfo>,
445 pub cost: CostInfo,
446 pub parse_evidence: Option<ParseEvidence>,
447 pub project: Option<ProjectInfo>,
448 pub git: Option<GitInfo>,
449 pub privacy: PrivacyInfo,
450 pub created_at: DateTime<Utc>,
451 pub imported_at: DateTime<Utc>,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
455pub struct SummaryMetadata {
456 pub summary_format: String,
457 pub summary_version: Option<String>,
458 pub total_sessions: Option<u64>,
459 pub total_messages: Option<u64>,
460 pub last_computed_at: Option<DateTime<Utc>>,
461}
462
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
464pub struct UsageSummary {
465 pub schema_version: String,
466 pub summary_id: SummaryId,
467 pub device_id: String,
468 pub provider: String,
469 pub source_id: SourceId,
470 pub provider_account_id: Option<ProviderAccountId>,
471 pub source: EventSource,
472 pub model: Option<ModelInfo>,
473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
474 pub models: Vec<SummaryModelUsage>,
475 pub usage: UsageCounts,
476 pub cost: CostInfo,
477 pub parse_evidence: Option<ParseEvidence>,
478 pub project: Option<ProjectInfo>,
479 pub privacy: PrivacyInfo,
480 pub metrics: Option<SummaryMetrics>,
481 pub period_start: Option<DateTime<Utc>>,
482 pub period_end: Option<DateTime<Utc>>,
483 pub observed_at: DateTime<Utc>,
484 pub metadata: SummaryMetadata,
485 pub imported_at: DateTime<Utc>,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
489pub struct SyncBatch {
490 pub schema_version: String,
491 pub batch_id: String,
492 pub device_id: String,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
494 pub sources: Vec<SourceLocation>,
495 #[serde(default, skip_serializing_if = "Vec::is_empty")]
496 pub accounts: Vec<ProviderAccount>,
497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub source_account_assignments: Vec<SourceAccountAssignment>,
499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
500 pub subscriptions: Vec<Subscription>,
501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 pub events: Vec<UsageEvent>,
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 pub summaries: Vec<UsageSummary>,
505 pub created_at: DateTime<Utc>,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
509pub struct SyncEntityCounts {
510 pub sources: u64,
511 pub accounts: u64,
512 #[serde(default)]
513 pub source_account_assignments: u64,
514 pub subscriptions: u64,
515 pub events: u64,
516 pub summaries: u64,
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
520pub struct SyncRejectedRecord {
521 pub kind: String,
522 pub id: Option<String>,
523 pub reason: String,
524}
525
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
527pub struct SyncAck {
528 pub schema_version: String,
529 pub batch_id: String,
530 pub accepted: SyncEntityCounts,
531 pub duplicates: SyncEntityCounts,
532 pub rejected: Vec<SyncRejectedRecord>,
533}
534
535#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
536pub struct DailyRollup {
537 pub schema_version: String,
538 pub date: String,
539 pub device_id: String,
540 pub total_input_tokens: u64,
541 pub total_cache_creation_tokens: u64,
542 pub total_cache_read_tokens: u64,
543 pub total_output_tokens: u64,
544 pub total_reasoning_tokens: u64,
545 pub total_tokens: u64,
546 pub total_events: u64,
547 pub total_sessions: u64,
548 pub estimated_cost_usd: Option<i64>, pub by_provider: Option<String>,
550 pub by_account: Option<String>,
551 pub updated_at: DateTime<Utc>,
552}
553
554impl SourceLocation {
555 #[must_use]
556 pub fn local_adapter(
557 provider: impl Into<String>,
558 adapter_id: impl Into<String>,
559 adapter_version: impl Into<String>,
560 path: &Path,
561 location_origin: LocationOrigin,
562 ) -> Self {
563 let provider = provider.into();
564 let adapter_id = adapter_id.into();
565 let adapter_version = adapter_version.into();
566 let path_hash = path_hash(path);
567 let now = Utc::now();
568 let source_id = source_id(&provider, SourceKind::LocalAdapter, &path_hash);
569
570 Self {
571 schema_version: SOURCE_LOCATION_SCHEMA_VERSION.to_string(),
572 source_id,
573 provider,
574 source_kind: SourceKind::LocalAdapter,
575 location_origin,
576 adapter_id: Some(adapter_id),
577 adapter_version: Some(adapter_version),
578 path_hash: Some(path_hash),
579 path_label: Some(display_path(path)),
580 enabled: true,
581 verification_mode: SourceVerificationMode::Auto,
582 verified_state_hash: None,
583 created_at: now,
584 updated_at: now,
585 }
586 }
587
588 #[must_use]
589 pub fn external_report(
590 provider: impl Into<String>,
591 adapter_id: impl Into<String>,
592 adapter_version: impl Into<String>,
593 path: &Path,
594 ) -> Self {
595 let provider = provider.into();
596 let adapter_id = adapter_id.into();
597 let adapter_version = adapter_version.into();
598 let path_hash = path_hash(path);
599 let now = Utc::now();
600 let source_id = source_id(&provider, SourceKind::ExternalReport, &path_hash);
601
602 Self {
603 schema_version: SOURCE_LOCATION_SCHEMA_VERSION.to_string(),
604 source_id,
605 provider,
606 source_kind: SourceKind::ExternalReport,
607 location_origin: LocationOrigin::Configured,
608 adapter_id: Some(adapter_id),
609 adapter_version: Some(adapter_version),
610 path_hash: Some(path_hash),
611 path_label: Some(display_path(path)),
612 enabled: true,
613 verification_mode: SourceVerificationMode::Disabled,
614 verified_state_hash: None,
615 created_at: now,
616 updated_at: now,
617 }
618 }
619
620 #[must_use]
621 pub fn reported_usage(
622 provider: impl Into<String>,
623 source_kind: SourceKind,
624 adapter_id: impl Into<String>,
625 adapter_version: impl Into<String>,
626 evidence_key: impl AsRef<str>,
627 path_label: Option<String>,
628 ) -> Self {
629 let provider = provider.into();
630 let adapter_id = adapter_id.into();
631 let adapter_version = adapter_version.into();
632 let path_hash = hash_text(evidence_key.as_ref());
633 let now = Utc::now();
634 let source_id = source_id(&provider, source_kind.clone(), &path_hash);
635
636 Self {
637 schema_version: SOURCE_LOCATION_SCHEMA_VERSION.to_string(),
638 source_id,
639 provider,
640 source_kind,
641 location_origin: LocationOrigin::Configured,
642 adapter_id: Some(adapter_id),
643 adapter_version: Some(adapter_version),
644 path_hash: Some(path_hash),
645 path_label,
646 enabled: true,
647 verification_mode: SourceVerificationMode::Disabled,
648 verified_state_hash: None,
649 created_at: now,
650 updated_at: now,
651 }
652 }
653}
654
655impl UsageCounts {
656 #[must_use]
657 pub fn computed_total(&self) -> u64 {
658 self.total_tokens.unwrap_or_else(|| {
659 self.input_tokens
660 .unwrap_or(0)
661 .saturating_add(self.output_tokens.unwrap_or(0))
662 .saturating_add(self.cache_creation_tokens.unwrap_or(0))
663 .saturating_add(self.cache_read_tokens.unwrap_or(0))
664 .saturating_add(self.reasoning_tokens.unwrap_or(0))
665 .saturating_add(self.local_prompt_eval_tokens.unwrap_or(0))
666 .saturating_add(self.local_eval_tokens.unwrap_or(0))
667 })
668 }
669}
670
671#[must_use]
672pub fn hash_text(value: &str) -> String {
673 let digest = Sha256::digest(value.as_bytes());
674 hex::encode(digest)
675}
676
677#[must_use]
678pub fn sanitize_project_for_sync(project: ProjectInfo) -> Option<ProjectInfo> {
679 if !project_has_stable_identity(&project) {
680 return None;
681 }
682 Some(project)
683}
684
685#[must_use]
686pub fn sanitize_summary_for_sync(mut summary: UsageSummary) -> UsageSummary {
687 summary.source.source_record_id = None;
688 if let Some(evidence) = summary.parse_evidence.as_mut() {
689 evidence.source_line_number = None;
690 evidence.source_record_id = None;
691 }
692 summary.project = summary.project.and_then(sanitize_project_for_sync);
693 if project_contains_file_paths(summary.project.as_ref()) {
694 summary.privacy.contains_file_paths = true;
695 }
696 summary
697}
698
699#[must_use]
700pub fn path_hash(path: &Path) -> String {
701 let canonical = canonical_display(path);
702 hash_text(&canonical)
703}
704
705#[must_use]
706pub fn source_id(provider: &str, source_kind: SourceKind, stable_key: &str) -> SourceId {
707 SourceId(format!(
708 "src_{}",
709 &hash_text(&format!("{provider}:{source_kind:?}:{stable_key}"))[..24]
710 ))
711}
712
713#[must_use]
714pub fn provider_account_id(provider: &str, stable_key: &str) -> ProviderAccountId {
715 ProviderAccountId(format!(
716 "acct_{}",
717 &hash_text(&format!("{provider}:{stable_key}"))[..24]
718 ))
719}
720
721#[must_use]
722pub fn normalize_provider_user_id(value: &str) -> String {
723 value.trim().to_string()
724}
725
726#[must_use]
727pub fn normalize_email(value: &str) -> String {
728 value.trim().to_ascii_lowercase()
729}
730
731fn default_identity_source_unknown() -> IdentitySource {
732 IdentitySource::Unknown
733}
734
735#[must_use]
736pub fn provider_account_stable_key(
737 provider_user_id: Option<&str>,
738 email: Option<&str>,
739) -> Option<String> {
740 provider_user_id
741 .map(normalize_provider_user_id)
742 .filter(|value| !value.is_empty())
743 .map(|value| format!("uid:{value}"))
744 .or_else(|| {
745 email
746 .map(normalize_email)
747 .filter(|value| !value.is_empty())
748 .map(|value| format!("email:{value}"))
749 })
750}
751
752#[must_use]
753pub fn provider_account_id_from_identity(
754 provider: &str,
755 provider_user_id: Option<&str>,
756 email: Option<&str>,
757) -> Option<ProviderAccountId> {
758 provider_account_stable_key(provider_user_id, email)
759 .map(|stable_key| provider_account_id(provider, &stable_key))
760}
761
762#[must_use]
763pub fn source_account_assignment_id(
764 source_id: &SourceId,
765 account: &ProviderAccountId,
766 started_at: DateTime<Utc>,
767) -> SourceAccountAssignmentId {
768 SourceAccountAssignmentId(format!(
769 "assign_{}",
770 &hash_text(&format!(
771 "{}:{}:{}",
772 source_id.0,
773 account.0,
774 started_at.to_rfc3339()
775 ))[..24]
776 ))
777}
778
779#[must_use]
780pub fn subscription_id(
781 provider: &str,
782 account: &ProviderAccountId,
783 plan: &str,
784 started_at: DateTime<Utc>,
785) -> SubscriptionId {
786 let account_key = account.0.as_str();
787 let started_at_key = started_at.to_rfc3339();
788 SubscriptionId(format!(
789 "sub_{}",
790 &hash_text(&format!("{provider}:{account_key}:{plan}:{started_at_key}"))[..24]
791 ))
792}
793
794#[must_use]
795pub fn event_id(
796 provider: &str,
797 source_id: &SourceId,
798 source_record_id: &str,
799 session_hash: Option<&str>,
800 timestamp: DateTime<Utc>,
801) -> EventId {
802 EventId(format!(
803 "evt_{}",
804 &hash_text(&format!(
805 "{provider}:{}:{source_record_id}:{}:{}",
806 source_id.0,
807 session_hash.unwrap_or(""),
808 timestamp.to_rfc3339()
809 ))[..32]
810 ))
811}
812
813#[must_use]
814pub fn semantic_event_id(provider: &str, source_id: &SourceId, semantic_key: &str) -> EventId {
815 EventId(format!(
816 "evt_{}",
817 &hash_text(&format!("{provider}:{}:{semantic_key}", source_id.0))[..32]
818 ))
819}
820
821#[must_use]
822pub fn summary_id(provider: &str, source_id: &SourceId, semantic_key: &str) -> SummaryId {
823 SummaryId(format!(
824 "sum_{}",
825 &hash_text(&format!("{provider}:{}:{semantic_key}", source_id.0))[..32]
826 ))
827}
828
829#[must_use]
830pub fn semantic_event_fingerprint(input: &SemanticFingerprintInput<'_>) -> String {
831 hash_text(&format!(
832 "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
833 input.provider,
834 input.source_id.0,
835 input.started_at.to_rfc3339(),
836 input.session_hash.unwrap_or(""),
837 input.project_key.unwrap_or(""),
838 input.model_name.unwrap_or("unknown"),
839 input.input_tokens.unwrap_or(0),
840 input.cache_read_tokens.unwrap_or(0),
841 input.cache_creation_tokens.unwrap_or(0),
842 input.output_tokens.unwrap_or(0),
843 input.reasoning_tokens.unwrap_or(0),
844 input.total_tokens
845 ))
846}
847
848pub struct SemanticFingerprintInput<'a> {
849 pub provider: &'a str,
850 pub source_id: &'a SourceId,
851 pub started_at: DateTime<Utc>,
852 pub session_hash: Option<&'a str>,
853 pub project_key: Option<&'a str>,
854 pub model_name: Option<&'a str>,
855 pub input_tokens: Option<u64>,
856 pub cache_read_tokens: Option<u64>,
857 pub cache_creation_tokens: Option<u64>,
858 pub output_tokens: Option<u64>,
859 pub reasoning_tokens: Option<u64>,
860 pub total_tokens: u64,
861}
862
863#[must_use]
864pub fn canonical_display(path: &Path) -> String {
865 path.canonicalize()
866 .unwrap_or_else(|_| expand_home(path))
867 .to_string_lossy()
868 .to_string()
869}
870
871#[must_use]
875pub fn display_path(path: &Path) -> String {
876 expand_home(path).to_string_lossy().to_string()
877}
878
879fn expand_home(path: &Path) -> PathBuf {
880 let text = path.to_string_lossy();
881 if let Some(stripped) = text.strip_prefix("~/") {
882 if let Some(home) = home_dir() {
883 return home.join(stripped);
884 }
885 }
886 path.to_path_buf()
887}
888
889#[must_use]
890pub fn home_dir() -> Option<PathBuf> {
891 std::env::var_os("HOME")
892 .or_else(|| std::env::var_os("USERPROFILE"))
893 .map(PathBuf::from)
894}
895
896#[must_use]
897pub fn expand_home_path(value: &str) -> PathBuf {
898 if value == "~" {
899 return home_dir().unwrap_or_else(|| PathBuf::from(value));
900 }
901 if let Some(rest) = value.strip_prefix("~/") {
902 if let Some(home) = home_dir() {
903 return home.join(rest);
904 }
905 }
906 PathBuf::from(value)
907}
908
909use chrono::Duration;
912use std::collections::{BTreeMap, BTreeSet};
913
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
915pub enum ReportPeriod {
916 LastDays(i64),
917 AllTime,
918}
919
920#[derive(Debug, Clone, Default)]
921pub struct UsageTotals {
922 pub input_tokens: u64,
923 pub cache_creation_tokens: u64,
924 pub cached_input_tokens: u64,
925 pub output_tokens: u64,
926 pub reasoning_tokens: u64,
927 pub total_tokens: u64,
928 pub estimated_cost_usd: Option<i64>, }
930
931impl UsageTotals {
932 pub fn add_event(&mut self, event: &UsageEvent) {
933 self.input_tokens += event.usage.input_tokens.unwrap_or(0);
934 self.cache_creation_tokens += event.usage.cache_creation_tokens.unwrap_or(0);
935 self.cached_input_tokens += event.usage.cache_read_tokens.unwrap_or(0);
936 self.output_tokens += event.usage.output_tokens.unwrap_or(0);
937 self.reasoning_tokens += event.usage.reasoning_tokens.unwrap_or(0);
938 self.total_tokens += event.usage.computed_total();
939 if let Some(cost) = event.cost.estimated_api_equivalent_usd {
940 self.estimated_cost_usd = Some(self.estimated_cost_usd.unwrap_or(0) + cost);
941 }
942 }
943
944 pub fn add_summary(&mut self, summary: &UsageSummary) {
945 self.input_tokens += summary.usage.input_tokens.unwrap_or(0);
946 self.cache_creation_tokens += summary.usage.cache_creation_tokens.unwrap_or(0);
947 self.cached_input_tokens += summary.usage.cache_read_tokens.unwrap_or(0);
948 self.output_tokens += summary.usage.output_tokens.unwrap_or(0);
949 self.reasoning_tokens += summary.usage.reasoning_tokens.unwrap_or(0);
950 self.total_tokens += summary.usage.computed_total();
951 if let Some(cost) = summary
952 .cost
953 .provider_reported_usd
954 .or(summary.cost.estimated_api_equivalent_usd)
955 {
956 self.estimated_cost_usd = Some(self.estimated_cost_usd.unwrap_or(0) + cost);
957 }
958 }
959
960 pub fn add_totals(&mut self, other: &UsageTotals) {
961 self.input_tokens += other.input_tokens;
962 self.cache_creation_tokens += other.cache_creation_tokens;
963 self.cached_input_tokens += other.cached_input_tokens;
964 self.output_tokens += other.output_tokens;
965 self.reasoning_tokens += other.reasoning_tokens;
966 self.total_tokens += other.total_tokens;
967 if let Some(cost) = other.estimated_cost_usd {
968 self.estimated_cost_usd = Some(self.estimated_cost_usd.unwrap_or(0) + cost);
969 }
970 }
971}
972
973#[derive(Debug, Clone)]
974pub struct UsageReportRow {
975 pub provider: String,
976 pub account: String,
977 pub events: u64,
978 pub usage: UsageTotals,
979 pub sources: BTreeSet<String>,
980 pub paths: BTreeSet<String>,
981}
982
983#[derive(Debug, Clone)]
984pub struct SummaryReportRow {
985 pub provider: String,
986 pub account: String,
987 pub kind: String,
988 pub summaries: u64,
989 pub usage: UsageTotals,
990 pub direct_event_usage: UsageTotals,
991 pub exact_overlap_summaries: u64,
992 pub observed_at: Option<DateTime<Utc>>,
993 pub sources: BTreeSet<String>,
994 pub paths: BTreeSet<String>,
995}
996
997#[derive(Debug, Clone)]
998pub struct SubscriptionReportRow {
999 pub subscription_id: SubscriptionId,
1000 pub provider: String,
1001 pub provider_account_id: ProviderAccountId,
1002 pub account: String,
1003 pub plan_name: String,
1004 pub price: i64, pub currency: String,
1006 pub billing_period: BillingPeriod,
1007 pub started_at: DateTime<Utc>,
1008 pub ended_at: Option<DateTime<Utc>>,
1009 pub status: SubscriptionStatus,
1010 pub events: u64,
1011 pub usage: UsageTotals,
1012 pub value_minus_price_usd: Option<i64>, pub value_to_price_ratio: Option<f64>,
1014}
1015
1016#[derive(Debug, Clone)]
1017pub struct UsageReport {
1018 pub label: String,
1019 pub since: Option<DateTime<Utc>>,
1020 pub until: DateTime<Utc>,
1021 pub rows: Vec<UsageReportRow>,
1022 pub summary_rows: Vec<SummaryReportRow>,
1023 pub subscription_rows: Vec<SubscriptionReportRow>,
1024 pub total_events: u64,
1025 pub total_usage: UsageTotals,
1026 pub total_summary_usage: UsageTotals,
1027}
1028
1029#[must_use]
1030pub fn build_usage_report(
1031 events: &[UsageEvent],
1032 summaries: &[UsageSummary],
1033 sources: &[SourceLocation],
1034 accounts: &[ProviderAccount],
1035 subscriptions: &[Subscription],
1036 period: ReportPeriod,
1037 now: DateTime<Utc>,
1038) -> UsageReport {
1039 let since = match period {
1040 ReportPeriod::LastDays(days) => Some(now - Duration::days(days)),
1041 ReportPeriod::AllTime => None,
1042 };
1043 let label = match period {
1044 ReportPeriod::LastDays(7) => "last 7 days".to_string(),
1045 ReportPeriod::LastDays(30) => "last 30 days".to_string(),
1046 ReportPeriod::LastDays(days) => format!("last {days} days"),
1047 ReportPeriod::AllTime => "all time".to_string(),
1048 };
1049
1050 let source_by_id: BTreeMap<_, _> = sources
1051 .iter()
1052 .map(|source| (source.source_id.0.as_str(), source))
1053 .collect();
1054 let account_by_id: BTreeMap<_, _> = accounts
1055 .iter()
1056 .map(|account| (account.provider_account_id.0.as_str(), account))
1057 .collect();
1058 let mut rows: BTreeMap<(String, String), UsageReportRow> = BTreeMap::new();
1059
1060 for event in events {
1061 if since.is_some_and(|since| event.session.started_at < since)
1062 || event.session.started_at > now
1063 {
1064 continue;
1065 }
1066
1067 let source = source_by_id.get(event.source_id.0.as_str()).copied();
1068 let account = report_account_label(event, &account_by_id);
1069 let key = (event.provider.clone(), account.clone());
1070 let row = rows.entry(key).or_insert_with(|| UsageReportRow {
1071 provider: event.provider.clone(),
1072 account,
1073 events: 0,
1074 usage: UsageTotals::default(),
1075 sources: BTreeSet::new(),
1076 paths: BTreeSet::new(),
1077 });
1078 row.events += 1;
1079 row.usage.add_event(event);
1080 row.sources.insert(event.source_id.0.clone());
1081 if let Some(source) = source {
1082 row.paths.insert(preview_path_label(source));
1083 }
1084 }
1085
1086 let mut summary_rows: BTreeMap<(String, String, String), SummaryReportRow> = BTreeMap::new();
1087 if matches!(period, ReportPeriod::AllTime) {
1088 for summary in summaries {
1089 if summary.observed_at > now {
1090 continue;
1091 }
1092
1093 let source = source_by_id.get(summary.source_id.0.as_str()).copied();
1094 let account =
1095 report_identity_label(summary.provider_account_id.as_ref(), &account_by_id);
1096 let kind = summary.metadata.summary_format.clone();
1097 let key = (summary.provider.clone(), account.clone(), kind.clone());
1098 let direct_overlap_usage =
1099 direct_usage_for_summary(summary, &account, events, &account_by_id, now);
1100 let exact_overlap =
1101 summary_usage_matches_direct_overlap(summary, &direct_overlap_usage);
1102 let row = summary_rows
1103 .entry(key.clone())
1104 .or_insert_with(|| SummaryReportRow {
1105 provider: summary.provider.clone(),
1106 account,
1107 kind,
1108 summaries: 0,
1109 usage: UsageTotals::default(),
1110 direct_event_usage: UsageTotals::default(),
1111 exact_overlap_summaries: 0,
1112 observed_at: None,
1113 sources: BTreeSet::new(),
1114 paths: BTreeSet::new(),
1115 });
1116 row.summaries += 1;
1117 row.usage.add_summary(summary);
1118 row.direct_event_usage.add_totals(&direct_overlap_usage);
1119 if exact_overlap {
1120 row.exact_overlap_summaries += 1;
1121 }
1122 row.observed_at = Some(
1123 row.observed_at
1124 .map(|observed_at| observed_at.max(summary.observed_at))
1125 .unwrap_or(summary.observed_at),
1126 );
1127 row.sources.insert(summary.source_id.0.clone());
1128 if let Some(source) = source {
1129 row.paths.insert(preview_path_label(source));
1130 }
1131 }
1132 }
1133
1134 let mut rows: Vec<_> = rows.into_values().collect();
1135 rows.sort_by(|left, right| {
1136 right
1137 .usage
1138 .total_tokens
1139 .cmp(&left.usage.total_tokens)
1140 .then_with(|| left.account.cmp(&right.account))
1141 });
1142 let total_events = rows.iter().map(|row| row.events).sum();
1143 let mut total_usage = UsageTotals::default();
1144 for row in &rows {
1145 total_usage.add_totals(&row.usage);
1146 }
1147 let mut summary_rows: Vec<_> = summary_rows.into_values().collect();
1148 summary_rows.sort_by(|left, right| {
1149 right
1150 .usage
1151 .total_tokens
1152 .cmp(&left.usage.total_tokens)
1153 .then_with(|| left.account.cmp(&right.account))
1154 .then_with(|| left.kind.cmp(&right.kind))
1155 });
1156 let mut total_summary_usage = UsageTotals::default();
1157 for row in &summary_rows {
1158 total_summary_usage.add_totals(&row.usage);
1159 }
1160 let subscription_rows =
1161 build_subscription_report_rows(events, subscriptions, &account_by_id, since, now);
1162
1163 UsageReport {
1164 label,
1165 since,
1166 until: now,
1167 rows,
1168 summary_rows,
1169 subscription_rows,
1170 total_events,
1171 total_usage,
1172 total_summary_usage,
1173 }
1174}
1175
1176fn report_account_label(event: &UsageEvent, accounts: &BTreeMap<&str, &ProviderAccount>) -> String {
1177 report_identity_label(event.provider_account_id.as_ref(), accounts)
1178}
1179
1180fn direct_usage_for_summary(
1181 summary: &UsageSummary,
1182 summary_account: &str,
1183 events: &[UsageEvent],
1184 accounts: &BTreeMap<&str, &ProviderAccount>,
1185 now: DateTime<Utc>,
1186) -> UsageTotals {
1187 let start = summary.period_start.unwrap_or(summary.observed_at);
1188 let end = summary.period_end.unwrap_or(summary.observed_at).min(now);
1189 let mut usage = UsageTotals::default();
1190 for event in events {
1191 if event.provider != summary.provider
1192 || event.session.started_at < start
1193 || event.session.started_at > end
1194 {
1195 continue;
1196 }
1197 if report_account_label(event, accounts) != summary_account {
1198 continue;
1199 }
1200 usage.add_event(event);
1201 }
1202 usage
1203}
1204
1205fn summary_usage_matches_direct_overlap(summary: &UsageSummary, direct: &UsageTotals) -> bool {
1206 if direct.total_tokens == 0 || summary.usage.computed_total() != direct.total_tokens {
1207 return false;
1208 }
1209 let summary_input = summary.usage.input_tokens.unwrap_or(0);
1210 let direct_input_matches = direct.input_tokens == summary_input
1211 || direct
1212 .input_tokens
1213 .saturating_sub(direct.cached_input_tokens)
1214 == summary_input;
1215 direct_input_matches
1216 && summary.usage.cache_creation_tokens.unwrap_or(0) == direct.cache_creation_tokens
1217 && summary.usage.cache_read_tokens.unwrap_or(0) == direct.cached_input_tokens
1218 && summary.usage.output_tokens.unwrap_or(0) == direct.output_tokens
1219 && summary.usage.reasoning_tokens.unwrap_or(0) == direct.reasoning_tokens
1220}
1221
1222fn report_identity_label(
1223 provider_account_id: Option<&ProviderAccountId>,
1224 accounts: &BTreeMap<&str, &ProviderAccount>,
1225) -> String {
1226 if let Some(account_id) = provider_account_id {
1227 if let Some(account) = accounts.get(account_id.0.as_str()) {
1228 return display_account_identity(account);
1229 }
1230 }
1231 provider_account_id
1232 .map(|id| id.0.clone())
1233 .unwrap_or_else(|| "unassigned".to_string())
1234}
1235
1236fn preview_path_label(source: &SourceLocation) -> String {
1237 let path = source.path_label.as_deref().unwrap_or("unknown");
1238 if let Some(home) = home_dir() {
1239 let home = home.to_string_lossy();
1240 if let Some(rest) = path.strip_prefix(home.as_ref()) {
1241 return format!("~{rest}");
1242 }
1243 }
1244 path.to_string()
1245}
1246
1247#[must_use]
1248pub fn timestamp_in_period(
1249 timestamp: DateTime<Utc>,
1250 started_at: DateTime<Utc>,
1251 ended_at: Option<DateTime<Utc>>,
1252) -> bool {
1253 timestamp >= started_at
1254 && ended_at
1255 .map(|ended_at| timestamp < ended_at)
1256 .unwrap_or(true)
1257}
1258
1259#[must_use]
1260pub fn periods_overlap(
1261 left_started_at: DateTime<Utc>,
1262 left_ended_at: Option<DateTime<Utc>>,
1263 right_started_at: DateTime<Utc>,
1264 right_ended_at: Option<DateTime<Utc>>,
1265) -> bool {
1266 let left_end = left_ended_at.unwrap_or(DateTime::<Utc>::MAX_UTC);
1267 let right_end = right_ended_at.unwrap_or(DateTime::<Utc>::MAX_UTC);
1268 left_started_at < right_end && right_started_at < left_end
1269}
1270
1271fn build_subscription_report_rows(
1272 events: &[UsageEvent],
1273 subscriptions: &[Subscription],
1274 accounts: &BTreeMap<&str, &ProviderAccount>,
1275 since: Option<DateTime<Utc>>,
1276 now: DateTime<Utc>,
1277) -> Vec<SubscriptionReportRow> {
1278 let mut rows = Vec::new();
1279 for subscription in subscriptions {
1280 let provider_account_id = &subscription.provider_account_id;
1281 let started_at = subscription.started_at;
1282 let ended_at = effective_subscription_ended_at(subscription);
1283 if !subscription_intersects_report_window(started_at, ended_at, since, now) {
1284 continue;
1285 }
1286 let mut usage = UsageTotals::default();
1287 let mut events_count = 0u64;
1288 for event in events {
1289 if event.provider != subscription.provider {
1290 continue;
1291 }
1292 if event.provider_account_id.as_ref() != Some(provider_account_id) {
1293 continue;
1294 }
1295 if since.is_some_and(|since| event.session.started_at < since)
1296 || event.session.started_at > now
1297 {
1298 continue;
1299 }
1300 if !timestamp_in_period(event.session.started_at, started_at, ended_at) {
1301 continue;
1302 }
1303 events_count += 1;
1304 usage.add_event(event);
1305 }
1306 let account = accounts
1307 .get(provider_account_id.0.as_str())
1308 .map(|account| display_account_identity(account))
1309 .unwrap_or_else(|| provider_account_id.0.clone());
1310 let (value_minus_price_usd, value_to_price_ratio) = subscription_value_metrics(
1311 subscription.price,
1312 &subscription.currency,
1313 usage.estimated_cost_usd,
1314 );
1315 rows.push(SubscriptionReportRow {
1316 subscription_id: subscription.subscription_id.clone(),
1317 provider: subscription.provider.clone(),
1318 provider_account_id: provider_account_id.clone(),
1319 account,
1320 plan_name: subscription.plan_name.clone(),
1321 price: subscription.price,
1322 currency: subscription.currency.clone(),
1323 billing_period: subscription.billing_period.clone(),
1324 started_at,
1325 ended_at,
1326 status: subscription.status.clone(),
1327 events: events_count,
1328 usage,
1329 value_minus_price_usd,
1330 value_to_price_ratio,
1331 });
1332 }
1333 rows.sort_by(|left, right| {
1334 right
1335 .usage
1336 .total_tokens
1337 .cmp(&left.usage.total_tokens)
1338 .then_with(|| left.started_at.cmp(&right.started_at))
1339 .then_with(|| left.plan_name.cmp(&right.plan_name))
1340 });
1341 rows
1342}
1343
1344fn effective_subscription_ended_at(subscription: &Subscription) -> Option<DateTime<Utc>> {
1345 if is_legacy_open_verified_subscription(subscription) {
1346 None
1347 } else {
1348 subscription.ended_at
1349 }
1350}
1351
1352fn is_legacy_open_verified_subscription(subscription: &Subscription) -> bool {
1353 subscription.status == SubscriptionStatus::Active
1354 && is_verified_subscription_source(&subscription.record_source)
1355 && subscription.ended_at.is_some()
1356 && subscription.ended_at == subscription.current_period_ends_at
1357}
1358
1359fn is_verified_subscription_source(source: &IdentitySource) -> bool {
1360 matches!(
1361 source,
1362 IdentitySource::LocalAuth
1363 | IdentitySource::ProviderAuth
1364 | IdentitySource::ProviderApi
1365 | IdentitySource::CookieOauth
1366 | IdentitySource::CliProbe
1367 )
1368}
1369
1370fn subscription_intersects_report_window(
1371 started_at: DateTime<Utc>,
1372 ended_at: Option<DateTime<Utc>>,
1373 since: Option<DateTime<Utc>>,
1374 now: DateTime<Utc>,
1375) -> bool {
1376 if started_at > now {
1377 return false;
1378 }
1379 let window_start = since.unwrap_or(DateTime::<Utc>::MIN_UTC);
1380 periods_overlap(
1381 started_at,
1382 ended_at,
1383 window_start,
1384 Some(now + Duration::seconds(1)),
1385 )
1386}
1387
1388fn subscription_value_metrics(
1389 price_cents: i64,
1390 currency: &str,
1391 estimated_cost_usd_cents: Option<i64>,
1392) -> (Option<i64>, Option<f64>) {
1393 if !currency.eq_ignore_ascii_case("USD") || price_cents <= 0 {
1394 return (None, None);
1395 }
1396 estimated_cost_usd_cents
1397 .map(|est_cents| {
1398 (
1399 Some(est_cents - price_cents),
1400 Some(est_cents as f64 / price_cents as f64),
1401 )
1402 })
1403 .unwrap_or((None, None))
1404}
1405
1406pub fn display_account_identity(account: &ProviderAccount) -> String {
1407 account
1408 .account_label
1409 .as_deref()
1410 .filter(|label| !label.trim().is_empty())
1411 .map(ToOwned::to_owned)
1412 .unwrap_or_else(|| account.provider_account_id.0.clone())
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418
1419 #[test]
1420 fn source_ids_are_stable_for_same_input() {
1421 let a = source_id("codex", SourceKind::LocalAdapter, "abc");
1422 let b = source_id("codex", SourceKind::LocalAdapter, "abc");
1423 assert_eq!(a, b);
1424 }
1425
1426 #[test]
1427 fn source_ids_change_by_provider() {
1428 let codex = source_id("codex", SourceKind::LocalAdapter, "abc");
1429 let claude = source_id("claude_code", SourceKind::LocalAdapter, "abc");
1430 assert_ne!(codex, claude);
1431 }
1432
1433 #[test]
1434 fn total_falls_back_to_parts() {
1435 let usage = UsageCounts {
1436 input_tokens: Some(10),
1437 output_tokens: Some(5),
1438 cache_read_tokens: Some(2),
1439 ..UsageCounts::default()
1440 };
1441 assert_eq!(usage.computed_total(), 17);
1442 }
1443
1444 #[test]
1445 fn schema_types_serialize() {
1446 let schema = schemars::schema_for!(UsageEvent);
1447 let json = serde_json::to_value(schema).expect("schema should serialize");
1448 assert!(json.get("title").is_some());
1449
1450 let schema = schemars::schema_for!(UsageSummary);
1451 let json = serde_json::to_value(schema).expect("summary schema should serialize");
1452 assert!(json.get("title").is_some());
1453 }
1454
1455 fn test_source(provider: &str, path: &str) -> SourceLocation {
1456 SourceLocation::local_adapter(
1457 provider,
1458 "test",
1459 "0",
1460 Path::new(path),
1461 LocationOrigin::Configured,
1462 )
1463 }
1464
1465 fn test_event(
1466 provider: &str,
1467 source: &SourceLocation,
1468 started_at: DateTime<Utc>,
1469 tokens: u64,
1470 cost_cents: Option<i64>,
1471 ) -> UsageEvent {
1472 UsageEvent {
1473 schema_version: USAGE_EVENT_SCHEMA_VERSION.to_string(),
1474 event_id: event_id(provider, &source.source_id, "rec", None, started_at),
1475 device_id: "d".to_string(),
1476 provider: provider.to_string(),
1477 source_id: source.source_id.clone(),
1478 provider_account_id: None,
1479 subscription_id: None,
1480 source: EventSource {
1481 adapter_id: "test".to_string(),
1482 adapter_version: "0".to_string(),
1483 source_kind: SourceKind::LocalAdapter,
1484 location_origin: Some(LocationOrigin::Configured),
1485 source_type: "jsonl".to_string(),
1486 source_path_hash: None,
1487 source_record_id: Some("rec".to_string()),
1488 parse_confidence: Confidence::High,
1489 },
1490 session: SessionInfo {
1491 session_id: "s".to_string(),
1492 local_session_id_hash: None,
1493 title: None,
1494 started_at,
1495 ended_at: None,
1496 duration_seconds: None,
1497 },
1498 model: None,
1499 usage: UsageCounts {
1500 input_tokens: Some(tokens / 2),
1501 output_tokens: Some(tokens / 2),
1502 total_tokens: Some(tokens),
1503 ..UsageCounts::default()
1504 },
1505 runtime: None,
1506 cost: CostInfo {
1507 currency: "USD".to_string(),
1508 estimated_api_equivalent_usd: cost_cents,
1509 provider_reported_usd: None,
1510 pricing_source: None,
1511 pricing_version: None,
1512 confidence: Confidence::Low,
1513 },
1514 parse_evidence: None,
1515 project: None,
1516 git: None,
1517 privacy: PrivacyInfo {
1518 mode: PrivacyMode::MetadataOnly,
1519 contains_prompt_text: false,
1520 contains_response_text: false,
1521 contains_file_paths: false,
1522 },
1523 created_at: started_at,
1524 imported_at: started_at,
1525 }
1526 }
1527
1528 fn test_summary(
1529 provider: &str,
1530 source: &SourceLocation,
1531 observed_at: DateTime<Utc>,
1532 period_start: DateTime<Utc>,
1533 period_end: DateTime<Utc>,
1534 tokens: u64,
1535 ) -> UsageSummary {
1536 UsageSummary {
1537 schema_version: USAGE_SUMMARY_SCHEMA_VERSION.to_string(),
1538 summary_id: summary_id(provider, &source.source_id, "sum"),
1539 device_id: "d".to_string(),
1540 provider: provider.to_string(),
1541 source_id: source.source_id.clone(),
1542 provider_account_id: None,
1543 source: EventSource {
1544 adapter_id: "test".to_string(),
1545 adapter_version: "0".to_string(),
1546 source_kind: SourceKind::LocalSummary,
1547 location_origin: Some(LocationOrigin::Configured),
1548 source_type: "cache".to_string(),
1549 source_path_hash: None,
1550 source_record_id: Some("rec".to_string()),
1551 parse_confidence: Confidence::Medium,
1552 },
1553 model: None,
1554 models: Vec::new(),
1555 usage: UsageCounts {
1556 input_tokens: Some(tokens),
1557 total_tokens: Some(tokens),
1558 ..UsageCounts::default()
1559 },
1560 cost: CostInfo {
1561 currency: "USD".to_string(),
1562 estimated_api_equivalent_usd: None,
1563 provider_reported_usd: None,
1564 pricing_source: None,
1565 pricing_version: None,
1566 confidence: Confidence::Low,
1567 },
1568 parse_evidence: None,
1569 project: None,
1570 privacy: PrivacyInfo {
1571 mode: PrivacyMode::MetadataOnly,
1572 contains_prompt_text: false,
1573 contains_response_text: false,
1574 contains_file_paths: false,
1575 },
1576 metrics: None,
1577 period_start: Some(period_start),
1578 period_end: Some(period_end),
1579 observed_at,
1580 metadata: SummaryMetadata {
1581 summary_format: "stats_cache".to_string(),
1582 summary_version: None,
1583 total_sessions: Some(1),
1584 total_messages: Some(10),
1585 last_computed_at: Some(observed_at),
1586 },
1587 imported_at: observed_at,
1588 }
1589 }
1590
1591 fn mk_dt(year: i32, month: u32, day: u32) -> DateTime<Utc> {
1592 chrono::NaiveDate::from_ymd_opt(year, month, day)
1593 .and_then(|d| d.and_hms_opt(0, 0, 0))
1594 .map(|dt| dt.and_utc())
1595 .expect("valid date")
1596 }
1597
1598 #[test]
1599 fn report_empty_inputs_returns_zero_totals() {
1600 let now = mk_dt(2026, 5, 25);
1601 let report = build_usage_report(&[], &[], &[], &[], &[], ReportPeriod::AllTime, now);
1602 assert_eq!(report.total_events, 0);
1603 assert_eq!(report.total_usage.total_tokens, 0);
1604 assert!(report.rows.is_empty());
1605 assert!(report.summary_rows.is_empty());
1606 }
1607
1608 #[test]
1609 fn report_filters_events_by_period() {
1610 let now = mk_dt(2026, 5, 25);
1611 let source = test_source("codex", "/tmp/codex");
1612 let recent = test_event("codex", &source, mk_dt(2026, 5, 24), 100, None);
1613 let old = test_event("codex", &source, mk_dt(2026, 5, 10), 200, None);
1614
1615 let report = build_usage_report(
1616 &[recent, old],
1617 &[],
1618 &[source],
1619 &[],
1620 &[],
1621 ReportPeriod::LastDays(7),
1622 now,
1623 );
1624
1625 assert_eq!(report.total_events, 1);
1626 assert_eq!(report.total_usage.total_tokens, 100);
1627 }
1628
1629 #[test]
1630 fn report_filters_out_future_events() {
1631 let now = mk_dt(2026, 5, 25);
1632 let source = test_source("codex", "/tmp/codex");
1633 let future = test_event("codex", &source, mk_dt(2026, 6, 1), 100, None);
1634 let present = test_event("codex", &source, now, 50, None);
1635
1636 let report = build_usage_report(
1637 &[future, present],
1638 &[],
1639 &[source],
1640 &[],
1641 &[],
1642 ReportPeriod::AllTime,
1643 now,
1644 );
1645
1646 assert_eq!(report.total_events, 1);
1647 assert_eq!(report.total_usage.total_tokens, 50);
1648 }
1649
1650 #[test]
1651 fn report_groups_events_by_provider_and_account() {
1652 let now = mk_dt(2026, 5, 25);
1653 let src = test_source("codex", "/tmp/codex");
1654 let e1 = test_event("codex", &src, now, 100, None);
1655 let e2 = test_event("codex", &src, now, 200, None);
1656
1657 let report =
1658 build_usage_report(&[e1, e2], &[], &[src], &[], &[], ReportPeriod::AllTime, now);
1659
1660 assert_eq!(report.rows.len(), 1);
1661 assert_eq!(report.rows[0].provider, "codex");
1662 assert_eq!(report.rows[0].events, 2);
1663 assert_eq!(report.rows[0].usage.total_tokens, 300);
1664 }
1665
1666 #[test]
1667 fn report_keeps_summaries_separate_from_events() {
1668 let now = mk_dt(2026, 5, 25);
1669 let src = test_source("claude_code", "/tmp/claude");
1670 let event = test_event("claude_code", &src, now, 100, None);
1671 let summary = test_summary(
1672 "claude_code",
1673 &src,
1674 now,
1675 mk_dt(2026, 5, 1),
1676 mk_dt(2026, 5, 25),
1677 500,
1678 );
1679
1680 let report = build_usage_report(
1681 &[event],
1682 &[summary],
1683 &[src],
1684 &[],
1685 &[],
1686 ReportPeriod::AllTime,
1687 now,
1688 );
1689
1690 assert_eq!(report.total_usage.total_tokens, 100);
1691 assert_eq!(report.total_summary_usage.total_tokens, 500);
1692 assert_eq!(report.summary_rows.len(), 1);
1693 assert_eq!(report.summary_rows[0].direct_event_usage.total_tokens, 100);
1695 }
1696
1697 #[test]
1698 fn report_hides_summaries_in_non_alltime_periods() {
1699 let now = mk_dt(2026, 5, 25);
1700 let src = test_source("claude_code", "/tmp/claude");
1701 let summary = test_summary(
1702 "claude_code",
1703 &src,
1704 now,
1705 mk_dt(2026, 5, 1),
1706 mk_dt(2026, 5, 25),
1707 500,
1708 );
1709
1710 let report = build_usage_report(
1711 &[],
1712 &[summary],
1713 &[src],
1714 &[],
1715 &[],
1716 ReportPeriod::LastDays(7),
1717 now,
1718 );
1719
1720 assert!(report.summary_rows.is_empty());
1721 }
1722
1723 #[test]
1724 fn subscription_rows_respect_past_end_time() {
1725 let now = mk_dt(2026, 6, 1);
1726 let src = test_source("codex", "/tmp/codex");
1727 let account_id = provider_account_id("codex", "email:verified@example.com");
1728 let account = ProviderAccount {
1729 schema_version: PROVIDER_ACCOUNT_SCHEMA_VERSION.to_string(),
1730 provider_account_id: account_id.clone(),
1731 provider: "codex".to_string(),
1732 identity_source: IdentitySource::LocalAuth,
1733 provider_user_id: Some("11111111-2222-4333-8444-555555555555".to_string()),
1734 provider_user_id_hash: None,
1735 email: Some("verified@example.com".to_string()),
1736 email_hash: None,
1737 org_id_hash: None,
1738 account_label: None,
1739 plan_name: Some("Plus".to_string()),
1740 confidence: Confidence::High,
1741 verified_at: Some(mk_dt(2026, 5, 3)),
1742 created_at: mk_dt(2026, 5, 3),
1743 updated_at: mk_dt(2026, 5, 3),
1744 };
1745 let mut before_end = test_event("codex", &src, mk_dt(2026, 5, 29), 100, Some(100));
1746 before_end.provider_account_id = Some(account_id.clone());
1747 let mut after_end = test_event("codex", &src, mk_dt(2026, 5, 31), 200, Some(200));
1748 after_end.provider_account_id = Some(account_id.clone());
1749 let subscription = Subscription {
1750 schema_version: SUBSCRIPTION_SCHEMA_VERSION.to_string(),
1751 subscription_id: subscription_id("codex", &account_id, "Plus", mk_dt(2026, 4, 30)),
1752 provider: "codex".to_string(),
1753 provider_account_id: account_id.clone(),
1754 plan_name: "Plus".to_string(),
1755 price: 2000,
1756 currency: "USD".to_string(),
1757 billing_period: BillingPeriod::Monthly,
1758 paid_at: Some(mk_dt(2026, 4, 30)),
1759 renewal_day: Some(30),
1760 started_at: mk_dt(2026, 4, 30),
1761 ended_at: Some(mk_dt(2026, 5, 30)),
1762 current_period_ends_at: Some(mk_dt(2026, 5, 30)),
1763 status: SubscriptionStatus::Cancelled,
1764 record_source: IdentitySource::LocalAuth,
1765 verified_at: Some(mk_dt(2026, 5, 3)),
1766 notes: None,
1767 };
1768
1769 let report = build_usage_report(
1770 &[before_end, after_end],
1771 &[],
1772 &[src],
1773 &[account],
1774 &[subscription],
1775 ReportPeriod::LastDays(30),
1776 now,
1777 );
1778
1779 assert_eq!(report.subscription_rows.len(), 1);
1780 assert_eq!(report.subscription_rows[0].account, account_id.0);
1781 assert_eq!(
1782 report.subscription_rows[0].ended_at,
1783 Some(mk_dt(2026, 5, 30))
1784 );
1785 assert_eq!(report.subscription_rows[0].events, 1);
1786 assert_eq!(report.subscription_rows[0].usage.total_tokens, 100);
1787 assert_eq!(
1788 report.subscription_rows[0].usage.estimated_cost_usd,
1789 Some(100)
1790 );
1791 }
1792
1793 #[test]
1794 fn subscription_rows_keep_legacy_verified_cycle_rows_open() {
1795 let now = mk_dt(2026, 6, 1);
1796 let src = test_source("codex", "/tmp/codex");
1797 let account_id = provider_account_id("codex", "email:verified@example.com");
1798 let account = ProviderAccount {
1799 schema_version: PROVIDER_ACCOUNT_SCHEMA_VERSION.to_string(),
1800 provider_account_id: account_id.clone(),
1801 provider: "codex".to_string(),
1802 identity_source: IdentitySource::LocalAuth,
1803 provider_user_id: None,
1804 provider_user_id_hash: None,
1805 email: Some("verified@example.com".to_string()),
1806 email_hash: None,
1807 org_id_hash: None,
1808 account_label: None,
1809 plan_name: Some("Plus".to_string()),
1810 confidence: Confidence::High,
1811 verified_at: Some(mk_dt(2026, 5, 3)),
1812 created_at: mk_dt(2026, 5, 3),
1813 updated_at: mk_dt(2026, 5, 3),
1814 };
1815 let mut before_cycle_end = test_event("codex", &src, mk_dt(2026, 5, 29), 100, Some(100));
1816 before_cycle_end.provider_account_id = Some(account_id.clone());
1817 let mut after_cycle_end = test_event("codex", &src, mk_dt(2026, 5, 31), 200, Some(200));
1818 after_cycle_end.provider_account_id = Some(account_id.clone());
1819 let subscription = Subscription {
1820 schema_version: SUBSCRIPTION_SCHEMA_VERSION.to_string(),
1821 subscription_id: subscription_id("codex", &account_id, "Plus", mk_dt(2026, 4, 30)),
1822 provider: "codex".to_string(),
1823 provider_account_id: account_id,
1824 plan_name: "Plus".to_string(),
1825 price: 2000,
1826 currency: "USD".to_string(),
1827 billing_period: BillingPeriod::Monthly,
1828 paid_at: Some(mk_dt(2026, 4, 30)),
1829 renewal_day: Some(30),
1830 started_at: mk_dt(2026, 4, 30),
1831 ended_at: Some(mk_dt(2026, 5, 30)),
1832 current_period_ends_at: Some(mk_dt(2026, 5, 30)),
1833 status: SubscriptionStatus::Active,
1834 record_source: IdentitySource::LocalAuth,
1835 verified_at: Some(mk_dt(2026, 5, 3)),
1836 notes: None,
1837 };
1838
1839 let report = build_usage_report(
1840 &[before_cycle_end, after_cycle_end],
1841 &[],
1842 &[src],
1843 &[account],
1844 &[subscription],
1845 ReportPeriod::LastDays(30),
1846 now,
1847 );
1848
1849 assert_eq!(report.subscription_rows.len(), 1);
1850 assert_eq!(report.subscription_rows[0].ended_at, None);
1851 assert_eq!(report.subscription_rows[0].events, 2);
1852 assert_eq!(report.subscription_rows[0].usage.total_tokens, 300);
1853 assert_eq!(
1854 report.subscription_rows[0].usage.estimated_cost_usd,
1855 Some(300)
1856 );
1857 }
1858
1859 #[test]
1860 fn report_uses_account_label_from_registry() {
1861 let now = mk_dt(2026, 5, 25);
1862 let src = test_source("codex", "/tmp/codex");
1863 let acct_id = provider_account_id("codex", "stable");
1864 let account = ProviderAccount {
1865 schema_version: PROVIDER_ACCOUNT_SCHEMA_VERSION.to_string(),
1866 provider_account_id: acct_id.clone(),
1867 provider: "codex".to_string(),
1868 identity_source: IdentitySource::UserConfigured,
1869 provider_user_id: None,
1870 provider_user_id_hash: None,
1871 email: None,
1872 email_hash: None,
1873 org_id_hash: None,
1874 account_label: Some("work".to_string()),
1875 plan_name: None,
1876 confidence: Confidence::Medium,
1877 verified_at: None,
1878 created_at: now,
1879 updated_at: now,
1880 };
1881 let mut event = test_event("codex", &src, now, 50, None);
1882 event.provider_account_id = Some(acct_id);
1883
1884 let report = build_usage_report(
1885 &[event],
1886 &[],
1887 &[src],
1888 &[account],
1889 &[],
1890 ReportPeriod::AllTime,
1891 now,
1892 );
1893
1894 assert_eq!(report.rows[0].account, "work");
1895 }
1896
1897 #[test]
1898 fn usage_totals_accumulate_cost() {
1899 let now = mk_dt(2026, 5, 25);
1900 let src = test_source("codex", "/tmp/codex");
1901 let e1 = test_event("codex", &src, now, 100, Some(1));
1902 let e2 = test_event("codex", &src, now, 200, Some(2));
1903
1904 let report =
1905 build_usage_report(&[e1, e2], &[], &[src], &[], &[], ReportPeriod::AllTime, now);
1906
1907 assert_eq!(report.total_usage.estimated_cost_usd, Some(3));
1908 }
1909
1910 #[test]
1911 fn computed_total_does_not_overflow() {
1912 let usage = UsageCounts {
1913 input_tokens: Some(u64::MAX),
1914 output_tokens: Some(u64::MAX),
1915 ..UsageCounts::default()
1916 };
1917 let total = usage.computed_total();
1918 assert_eq!(total, u64::MAX);
1919 }
1920
1921 #[test]
1922 fn display_path_expands_home_but_avoids_canonicalize() {
1923 let p = Path::new("~/relative/test");
1924 let displayed = display_path(p);
1925 assert!(displayed.contains("relative/test"));
1926 if let Some(home) = home_dir() {
1928 let home_str = home.to_string_lossy();
1929 if displayed.starts_with(home_str.as_ref()) {
1930 }
1932 }
1933 }
1934
1935 #[test]
1936 fn path_hash_remains_stable_via_canonical_display() {
1937 let p = Path::new("/tmp/nonexistent-for-test");
1938 let h1 = path_hash(p);
1939 let h2 = path_hash(p);
1940 assert_eq!(h1, h2);
1941 }
1942
1943 #[test]
1944 fn bare_project_id_is_not_a_stable_project_identity() {
1945 let project = ProjectInfo {
1946 project_id: "project_bare".to_string(),
1947 project_label: Some("Bare".to_string()),
1948 repo_remote_hash: None,
1949 repo_label: None,
1950 branch_hash: None,
1951 branch_label: None,
1952 path_hash: None,
1953 path_label: None,
1954 };
1955
1956 assert!(!project_has_stable_identity(&project));
1957 assert_eq!(project_bucket_key(Some(&project)), "none");
1958 }
1959
1960 #[test]
1961 fn sanitize_project_for_sync_preserves_path_only_project_labels() {
1962 let project = ProjectInfo {
1963 project_id: "project_path_only".to_string(),
1964 project_label: Some("Scratch".to_string()),
1965 repo_remote_hash: None,
1966 repo_label: None,
1967 branch_hash: None,
1968 branch_label: None,
1969 path_hash: Some("path-hash".to_string()),
1970 path_label: Some("/Users/example/Scratch".to_string()),
1971 };
1972
1973 let sanitized = sanitize_project_for_sync(project).expect("stable path identity");
1974
1975 assert_eq!(sanitized.repo_remote_hash, None);
1976 assert_eq!(sanitized.path_hash.as_deref(), Some("path-hash"));
1977 assert_eq!(
1978 sanitized.path_label.as_deref(),
1979 Some("/Users/example/Scratch")
1980 );
1981 assert!(project_contains_file_paths(Some(&sanitized)));
1982 }
1983
1984 #[test]
1985 fn sanitize_project_for_sync_drops_bare_project_ids() {
1986 let project = ProjectInfo {
1987 project_id: "project_bare".to_string(),
1988 project_label: Some("Bare".to_string()),
1989 repo_remote_hash: None,
1990 repo_label: None,
1991 branch_hash: None,
1992 branch_label: None,
1993 path_hash: None,
1994 path_label: Some("/Users/example/Bare".to_string()),
1995 };
1996
1997 assert!(sanitize_project_for_sync(project).is_none());
1998 }
1999
2000 #[test]
2001 fn sanitize_summary_for_sync_marks_project_path_labels_as_file_paths() {
2002 let now = mk_dt(2026, 5, 25);
2003 let source = test_source("codex", "/tmp/codex");
2004 let mut summary = test_summary("codex", &source, now, now, now, 100);
2005 summary.project = Some(ProjectInfo {
2006 project_id: "project_path_only".to_string(),
2007 project_label: Some("Scratch".to_string()),
2008 repo_remote_hash: None,
2009 repo_label: None,
2010 branch_hash: None,
2011 branch_label: None,
2012 path_hash: Some("path-hash".to_string()),
2013 path_label: Some("/Users/example/Scratch".to_string()),
2014 });
2015
2016 let sanitized = sanitize_summary_for_sync(summary);
2017
2018 assert_eq!(
2019 sanitized
2020 .project
2021 .as_ref()
2022 .and_then(|project| project.path_label.as_deref()),
2023 Some("/Users/example/Scratch")
2024 );
2025 assert!(sanitized.privacy.contains_file_paths);
2026 }
2027
2028 #[test]
2029 fn preview_path_label_uses_display_label() {
2030 let mut source = test_source("codex", "/tmp/codex");
2031 source.path_label = Some("/home/testuser/work/codex".to_string());
2032 let preview = preview_path_label(&source);
2033 assert!(preview.contains("codex") || preview.contains("work"));
2035 }
2036}