memvid_cli/
org_ticket_cache.rs1use std::fs;
7use std::path::PathBuf;
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11
12use crate::api::{fetch_org_ticket, OrgTicket, OrgTicketResponse};
13use crate::config::CliConfig;
14
15const ORG_TICKET_FILE: &str = "org_ticket.json";
16
17const WRITE_OP_REFRESH_SECS: i64 = 300;
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct CachedOrgTicket {
23 pub ticket: OrgTicket,
24 pub plan_id: String,
25 pub plan_name: String,
26 pub org_id: String,
27 pub org_name: String,
28 pub total_storage_bytes: u64,
29 pub subscription_status: String,
30 #[serde(default)]
32 pub plan_start_date: Option<String>,
33 #[serde(default)]
35 pub current_period_end: Option<String>,
36 #[serde(default)]
38 pub plan_end_date: Option<String>,
39 #[serde(default)]
41 pub cached_at: i64,
42}
43
44impl CachedOrgTicket {
45 pub fn is_expired(&self) -> bool {
47 self.ticket.is_expired()
48 }
49
50 pub fn capacity_bytes(&self) -> u64 {
52 self.ticket.capacity_bytes
53 }
54
55 pub fn is_paid(&self) -> bool {
57 self.ticket.is_paid()
58 }
59
60 pub fn is_in_grace_period(&self) -> bool {
62 if self.subscription_status != "canceled" {
63 return false;
64 }
65 if let Some(ref end_date) = self.plan_end_date {
66 if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
68 return end > Utc::now();
69 }
70 }
71 false
72 }
73
74 pub fn grace_period_days_remaining(&self) -> Option<i64> {
76 if self.subscription_status != "canceled" {
77 return None;
78 }
79 if let Some(ref end_date) = self.plan_end_date {
80 if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
81 let now = Utc::now();
82 if end > now {
83 let duration = end.signed_duration_since(now);
84 return Some(duration.num_days());
85 }
86 }
87 }
88 None
89 }
90
91 pub fn is_stale_for_writes(&self) -> bool {
95 if self.cached_at == 0 {
96 return true;
98 }
99 let now = Utc::now().timestamp();
100 now - self.cached_at > WRITE_OP_REFRESH_SECS
101 }
102}
103
104fn cache_path(config: &CliConfig) -> PathBuf {
106 config.cache_dir.join(ORG_TICKET_FILE)
107}
108
109pub fn load(config: &CliConfig) -> Result<CachedOrgTicket> {
111 let path = cache_path(config);
112 let data = fs::read(&path).with_context(|| "no cached org ticket available")?;
113 let cached: CachedOrgTicket = serde_json::from_slice(&data)
114 .with_context(|| format!("failed to parse cached org ticket: {}", path.display()))?;
115 Ok(cached)
116}
117
118pub fn store(config: &CliConfig, response: &OrgTicketResponse) -> Result<()> {
120 let path = cache_path(config);
121 if let Some(parent) = path.parent() {
122 fs::create_dir_all(parent).with_context(|| {
123 format!(
124 "failed to create org ticket cache directory: {}",
125 parent.display()
126 )
127 })?;
128 }
129
130 let cached = CachedOrgTicket {
131 ticket: response.ticket.clone(),
132 plan_id: response.plan.id.clone(),
133 plan_name: response.plan.name.clone(),
134 org_id: response.organisation.id.clone(),
135 org_name: response.organisation.name.clone(),
136 total_storage_bytes: response.organisation.total_storage_bytes,
137 subscription_status: response.subscription.status.clone(),
138 plan_start_date: response.subscription.plan_start_date.clone(),
139 current_period_end: response.subscription.current_period_end.clone(),
140 plan_end_date: response.subscription.plan_end_date.clone(),
141 cached_at: Utc::now().timestamp(),
142 };
143
144 let tmp = path.with_extension("json.tmp");
145 let data = serde_json::to_vec_pretty(&cached)?;
146 fs::write(&tmp, data)
147 .with_context(|| format!("failed to write org ticket cache file: {}", tmp.display()))?;
148 fs::rename(&tmp, &path).with_context(|| {
149 format!(
150 "failed to atomically persist org ticket cache file: {}",
151 path.display()
152 )
153 })?;
154 Ok(())
155}
156
157pub fn clear(config: &CliConfig) -> Result<()> {
159 let path = cache_path(config);
160 if path.exists() {
161 fs::remove_file(&path).with_context(|| {
162 format!("failed to remove org ticket cache file: {}", path.display())
163 })?;
164 }
165 Ok(())
166}
167
168pub fn get_or_refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
176 if let Ok(cached) = load(config) {
178 if !cached.is_expired() {
179 return Ok(cached);
180 }
181 log::debug!("Cached org ticket expired, refreshing...");
182 }
183
184 refresh(config)
186}
187
188pub fn refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
190 log::debug!("Fetching org ticket from API...");
191 let response = fetch_org_ticket(config)?;
192 store(config, &response)?;
193 load(config)
194}
195
196pub fn get_optional(config: &CliConfig) -> Option<CachedOrgTicket> {
205 if config.api_key.is_none() {
207 log::debug!("No API key set, using free tier limits");
208 return None;
209 }
210
211 let needs_fetch = match load(config) {
213 Ok(cached) => cached.is_expired(),
214 Err(_) => true,
215 };
216
217 if needs_fetch {
218 log::debug!("Auto-syncing plan ticket from dashboard...");
220 match refresh(config) {
221 Ok(ticket) => {
222 log::info!(
223 "Plan synced: {} ({})",
224 ticket.plan_name,
225 format_capacity(ticket.capacity_bytes())
226 );
227 return Some(ticket);
228 }
229 Err(err) => {
230 let err_str = err.to_string();
232 if err_str.contains("Invalid API key") || err_str.contains("401") {
233 log::debug!("API key invalid, using free tier limits");
234 } else {
235 log::warn!("Failed to sync plan (using free tier limits): {}", err);
237 }
238 return None;
239 }
240 }
241 }
242
243 match load(config) {
245 Ok(ticket) => Some(ticket),
246 Err(_) => None,
247 }
248}
249
250fn format_capacity(bytes: u64) -> String {
252 if bytes >= 1024 * 1024 * 1024 * 1024 {
253 format!(
254 "{:.1} TB",
255 bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0)
256 )
257 } else if bytes >= 1024 * 1024 * 1024 {
258 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
259 } else if bytes >= 1024 * 1024 {
260 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
261 } else if bytes >= 1024 {
262 format!("{:.1} KB", bytes as f64 / 1024.0)
263 } else {
264 format!("{} B", bytes)
265 }
266}
267
268pub fn get_fresh_for_writes(config: &CliConfig) -> Option<CachedOrgTicket> {
282 if config.api_key.is_none() {
284 return None;
285 }
286
287 let needs_refresh = match load(config) {
289 Ok(cached) => {
290 if cached.is_expired() {
291 true
292 } else {
293 let status = cached.subscription_status.as_str();
295 if status == "active" || status == "trialing" {
296 cached.is_stale_for_writes()
298 } else {
299 log::debug!(
301 "Non-active status '{}', refreshing to check for reactivation",
302 status
303 );
304 true
305 }
306 }
307 }
308 Err(_) => true,
309 };
310
311 if needs_refresh {
312 log::debug!("Refreshing ticket for write operation...");
313 match refresh(config) {
314 Ok(ticket) => return Some(ticket),
315 Err(err) => {
316 let err_str = err.to_string();
317 if err_str.contains("Invalid API key") || err_str.contains("401") {
318 log::debug!("API key invalid, using free tier limits");
319 } else {
320 log::warn!("Failed to refresh ticket: {}", err);
321 }
322 return None;
323 }
324 }
325 }
326
327 load(config).ok()
329}