Skip to main content

statsai_core/
lib.rs

1//! Core schemas and ID helpers for `statsai`.
2
3use 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, // minor units (cents) of the currency
185    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, // minor units (cents) of the currency
214    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    /// End-to-end request or turn duration, not time to first token.
271    pub latency_ms: Option<u64>,
272    /// Provenance of latency_ms when the adapter can distinguish it.
273    pub latency_source: Option<LatencySource>,
274    /// Time from request start until the first visible token arrives.
275    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    /// Aggregated end-to-end request or turn duration, not TTFT.
309    pub latency_ms: Option<MetricStats>,
310    pub time_to_first_token_ms: Option<MetricStats>,
311    /// Per-turn generated throughput distribution across tracked turns.
312    pub generated_tps: Option<MetricStats>,
313    /// Per-turn visible throughput distribution across tracked turns.
314    pub visible_tps: Option<MetricStats>,
315    /// Overall generated throughput across tracked active time.
316    pub overall_generated_tps: Option<f64>,
317    /// Overall visible throughput across tracked active time.
318    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>, // cents USD
331    pub provider_reported_usd: Option<i64>,        // cents USD
332    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>, // cents USD
549    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/// Display-friendly path normalization.
872/// Expands `~` for home but does NOT perform filesystem canonicalization
873/// (to avoid symlink/mount identity changes for labels).
874#[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
909// ── Report building ────────────────────────────────────────────
910
911use 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>, // cents USD
929}
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, // minor units (cents) of the currency
1005    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>, // cents USD
1013    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        // Direct event usage within summary period
1694        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        // should not resolve to absolute via fs if ~ expanded
1927        if let Some(home) = home_dir() {
1928            let home_str = home.to_string_lossy();
1929            if displayed.starts_with(home_str.as_ref()) {
1930                // expanded, good
1931            }
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        // if home matches, abbreviates; else full
2034        assert!(preview.contains("codex") || preview.contains("work"));
2035    }
2036}