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)
113 .with_context(|| "no cached org ticket available")?;
114 let cached: CachedOrgTicket = serde_json::from_slice(&data)
115 .with_context(|| format!("failed to parse cached org ticket: {}", path.display()))?;
116 Ok(cached)
117}
118
119pub fn store(config: &CliConfig, response: &OrgTicketResponse) -> Result<()> {
121 let path = cache_path(config);
122 if let Some(parent) = path.parent() {
123 fs::create_dir_all(parent).with_context(|| {
124 format!(
125 "failed to create org ticket cache directory: {}",
126 parent.display()
127 )
128 })?;
129 }
130
131 let cached = CachedOrgTicket {
132 ticket: response.ticket.clone(),
133 plan_id: response.plan.id.clone(),
134 plan_name: response.plan.name.clone(),
135 org_id: response.organisation.id.clone(),
136 org_name: response.organisation.name.clone(),
137 total_storage_bytes: response.organisation.total_storage_bytes,
138 subscription_status: response.subscription.status.clone(),
139 plan_start_date: response.subscription.plan_start_date.clone(),
140 current_period_end: response.subscription.current_period_end.clone(),
141 plan_end_date: response.subscription.plan_end_date.clone(),
142 cached_at: Utc::now().timestamp(),
143 };
144
145 let tmp = path.with_extension("json.tmp");
146 let data = serde_json::to_vec_pretty(&cached)?;
147 fs::write(&tmp, data)
148 .with_context(|| format!("failed to write org ticket cache file: {}", tmp.display()))?;
149 fs::rename(&tmp, &path).with_context(|| {
150 format!(
151 "failed to atomically persist org ticket cache file: {}",
152 path.display()
153 )
154 })?;
155 Ok(())
156}
157
158pub fn clear(config: &CliConfig) -> Result<()> {
160 let path = cache_path(config);
161 if path.exists() {
162 fs::remove_file(&path).with_context(|| {
163 format!("failed to remove org ticket cache file: {}", path.display())
164 })?;
165 }
166 Ok(())
167}
168
169pub fn get_or_refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
177 if let Ok(cached) = load(config) {
179 if !cached.is_expired() {
180 return Ok(cached);
181 }
182 log::debug!("Cached org ticket expired, refreshing...");
183 }
184
185 refresh(config)
187}
188
189pub fn refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
191 log::debug!("Fetching org ticket from API...");
192 let response = fetch_org_ticket(config)?;
193 store(config, &response)?;
194 load(config)
195}
196
197pub fn get_optional(config: &CliConfig) -> Option<CachedOrgTicket> {
206 if config.api_key.is_none() {
208 log::debug!("No API key set, using free tier limits");
209 return None;
210 }
211
212 let needs_fetch = match load(config) {
214 Ok(cached) => cached.is_expired(),
215 Err(_) => true,
216 };
217
218 if needs_fetch {
219 log::debug!("Auto-syncing plan ticket from dashboard...");
221 match refresh(config) {
222 Ok(ticket) => {
223 log::info!(
224 "Plan synced: {} ({})",
225 ticket.plan_name,
226 format_capacity(ticket.capacity_bytes())
227 );
228 return Some(ticket);
229 }
230 Err(err) => {
231 let err_str = err.to_string();
233 if err_str.contains("Invalid API key") || err_str.contains("401") {
234 log::debug!("API key invalid, using free tier limits");
235 } else {
236 log::warn!("Failed to sync plan (using free tier limits): {}", err);
238 }
239 return None;
240 }
241 }
242 }
243
244 match load(config) {
246 Ok(ticket) => Some(ticket),
247 Err(_) => None,
248 }
249}
250
251fn format_capacity(bytes: u64) -> String {
253 if bytes >= 1024 * 1024 * 1024 * 1024 {
254 format!("{:.1} TB", bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0))
255 } else if bytes >= 1024 * 1024 * 1024 {
256 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
257 } else if bytes >= 1024 * 1024 {
258 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
259 } else if bytes >= 1024 {
260 format!("{:.1} KB", bytes as f64 / 1024.0)
261 } else {
262 format!("{} B", bytes)
263 }
264}
265
266pub fn get_fresh_for_writes(config: &CliConfig) -> Option<CachedOrgTicket> {
280 if config.api_key.is_none() {
282 return None;
283 }
284
285 let needs_refresh = match load(config) {
287 Ok(cached) => {
288 if cached.is_expired() {
289 true
290 } else {
291 let status = cached.subscription_status.as_str();
293 if status == "active" || status == "trialing" {
294 cached.is_stale_for_writes()
296 } else {
297 log::debug!("Non-active status '{}', refreshing to check for reactivation", status);
299 true
300 }
301 }
302 }
303 Err(_) => true,
304 };
305
306 if needs_refresh {
307 log::debug!("Refreshing ticket for write operation...");
308 match refresh(config) {
309 Ok(ticket) => return Some(ticket),
310 Err(err) => {
311 let err_str = err.to_string();
312 if err_str.contains("Invalid API key") || err_str.contains("401") {
313 log::debug!("API key invalid, using free tier limits");
314 } else {
315 log::warn!("Failed to refresh ticket: {}", err);
316 }
317 return None;
318 }
319 }
320 }
321
322 load(config).ok()
324}