memvid_cli/
org_ticket_cache.rs

1//! Organisation-level ticket caching
2//!
3//! Caches org-level tickets locally to avoid repeated API calls.
4//! Tickets are automatically refreshed when expired.
5
6use std::fs;
7use std::path::PathBuf;
8
9use anyhow::{Context, Result};
10
11use crate::api::{fetch_org_ticket, OrgTicket, OrgTicketResponse};
12use crate::config::CliConfig;
13
14const ORG_TICKET_FILE: &str = "org_ticket.json";
15
16/// Cached org ticket with metadata
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct CachedOrgTicket {
19    pub ticket: OrgTicket,
20    pub plan_id: String,
21    pub plan_name: String,
22    pub org_id: String,
23    pub org_name: String,
24    pub total_storage_bytes: u64,
25    pub subscription_status: String,
26}
27
28impl CachedOrgTicket {
29    /// Check if the cached ticket has expired
30    pub fn is_expired(&self) -> bool {
31        self.ticket.is_expired()
32    }
33
34    /// Get capacity in bytes from the ticket
35    pub fn capacity_bytes(&self) -> u64 {
36        self.ticket.capacity_bytes
37    }
38
39    /// Check if the plan is a paid plan
40    pub fn is_paid(&self) -> bool {
41        self.ticket.is_paid()
42    }
43}
44
45/// Get the path to the org ticket cache file
46fn cache_path(config: &CliConfig) -> PathBuf {
47    config.cache_dir.join(ORG_TICKET_FILE)
48}
49
50/// Load a cached org ticket from disk
51pub fn load(config: &CliConfig) -> Result<CachedOrgTicket> {
52    let path = cache_path(config);
53    let data = fs::read(&path)
54        .with_context(|| "no cached org ticket available")?;
55    let cached: CachedOrgTicket = serde_json::from_slice(&data)
56        .with_context(|| format!("failed to parse cached org ticket: {}", path.display()))?;
57    Ok(cached)
58}
59
60/// Store an org ticket response to disk
61pub fn store(config: &CliConfig, response: &OrgTicketResponse) -> Result<()> {
62    let path = cache_path(config);
63    if let Some(parent) = path.parent() {
64        fs::create_dir_all(parent).with_context(|| {
65            format!(
66                "failed to create org ticket cache directory: {}",
67                parent.display()
68            )
69        })?;
70    }
71
72    let cached = CachedOrgTicket {
73        ticket: response.ticket.clone(),
74        plan_id: response.plan.id.clone(),
75        plan_name: response.plan.name.clone(),
76        org_id: response.organisation.id.clone(),
77        org_name: response.organisation.name.clone(),
78        total_storage_bytes: response.organisation.total_storage_bytes,
79        subscription_status: response.subscription.status.clone(),
80    };
81
82    let tmp = path.with_extension("json.tmp");
83    let data = serde_json::to_vec_pretty(&cached)?;
84    fs::write(&tmp, data)
85        .with_context(|| format!("failed to write org ticket cache file: {}", tmp.display()))?;
86    fs::rename(&tmp, &path).with_context(|| {
87        format!(
88            "failed to atomically persist org ticket cache file: {}",
89            path.display()
90        )
91    })?;
92    Ok(())
93}
94
95/// Clear the cached org ticket
96pub fn clear(config: &CliConfig) -> Result<()> {
97    let path = cache_path(config);
98    if path.exists() {
99        fs::remove_file(&path).with_context(|| {
100            format!("failed to remove org ticket cache file: {}", path.display())
101        })?;
102    }
103    Ok(())
104}
105
106/// Get a valid org ticket, fetching from API if needed
107///
108/// This function:
109/// 1. Tries to load a cached ticket
110/// 2. If cached ticket is valid (not expired), returns it
111/// 3. If no cache or expired, fetches a new ticket from the API
112/// 4. Caches the new ticket and returns it
113pub fn get_or_refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
114    // Try cached ticket first
115    if let Ok(cached) = load(config) {
116        if !cached.is_expired() {
117            return Ok(cached);
118        }
119        log::debug!("Cached org ticket expired, refreshing...");
120    }
121
122    // Fetch new ticket
123    refresh(config)
124}
125
126/// Force refresh the org ticket from the API
127pub fn refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
128    log::debug!("Fetching org ticket from API...");
129    let response = fetch_org_ticket(config)?;
130    store(config, &response)?;
131    load(config)
132}
133
134/// Get org ticket if API key is configured, otherwise return None
135///
136/// This is useful for optional ticket validation - if no API key is set,
137/// we fall back to free tier limits without requiring authentication.
138///
139/// When API key is set, this automatically syncs the plan ticket from the
140/// dashboard if needed (first use or expired). This enables seamless
141/// capacity validation without explicit `memvid plan sync`.
142pub fn get_optional(config: &CliConfig) -> Option<CachedOrgTicket> {
143    // No API key = free tier, silent fallback
144    if config.api_key.is_none() {
145        log::debug!("No API key set, using free tier limits");
146        return None;
147    }
148
149    // Check if we need to fetch (no cache or expired)
150    let needs_fetch = match load(config) {
151        Ok(cached) => cached.is_expired(),
152        Err(_) => true,
153    };
154
155    if needs_fetch {
156        // Silent auto-sync on first use or when expired
157        log::debug!("Auto-syncing plan ticket from dashboard...");
158        match refresh(config) {
159            Ok(ticket) => {
160                log::info!(
161                    "Plan synced: {} ({})",
162                    ticket.plan_name,
163                    format_capacity(ticket.capacity_bytes())
164                );
165                return Some(ticket);
166            }
167            Err(err) => {
168                // Check if it's an auth error - silently fall back to free tier
169                let err_str = err.to_string();
170                if err_str.contains("Invalid API key") || err_str.contains("401") {
171                    log::debug!("API key invalid, using free tier limits");
172                } else {
173                    // Only warn for non-auth errors (network issues, etc.)
174                    log::warn!("Failed to sync plan (using free tier limits): {}", err);
175                }
176                return None;
177            }
178        }
179    }
180
181    // Use cached ticket
182    match load(config) {
183        Ok(ticket) => Some(ticket),
184        Err(_) => None,
185    }
186}
187
188/// Format capacity bytes for display
189fn format_capacity(bytes: u64) -> String {
190    if bytes >= 1024 * 1024 * 1024 * 1024 {
191        format!("{:.1} TB", bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0))
192    } else if bytes >= 1024 * 1024 * 1024 {
193        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
194    } else if bytes >= 1024 * 1024 {
195        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
196    } else if bytes >= 1024 {
197        format!("{:.1} KB", bytes as f64 / 1024.0)
198    } else {
199        format!("{} B", bytes)
200    }
201}