use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use chrono::Utc;
use crate::api::{fetch_org_ticket, OrgTicket, OrgTicketResponse};
use crate::config::CliConfig;
const ORG_TICKET_FILE: &str = "org_ticket.json";
const WRITE_OP_REFRESH_SECS: i64 = 300;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedOrgTicket {
pub ticket: OrgTicket,
pub plan_id: String,
pub plan_name: String,
pub org_id: String,
pub org_name: String,
pub total_storage_bytes: u64,
pub subscription_status: String,
#[serde(default)]
pub plan_start_date: Option<String>,
#[serde(default)]
pub current_period_end: Option<String>,
#[serde(default)]
pub plan_end_date: Option<String>,
#[serde(default)]
pub cached_at: i64,
}
impl CachedOrgTicket {
pub fn is_expired(&self) -> bool {
self.ticket.is_expired()
}
pub fn capacity_bytes(&self) -> u64 {
self.ticket.capacity_bytes
}
pub fn is_paid(&self) -> bool {
self.ticket.is_paid()
}
pub fn is_in_grace_period(&self) -> bool {
if self.subscription_status != "canceled" {
return false;
}
if let Some(ref end_date) = self.plan_end_date {
if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
return end > Utc::now();
}
}
false
}
pub fn grace_period_days_remaining(&self) -> Option<i64> {
if self.subscription_status != "canceled" {
return None;
}
if let Some(ref end_date) = self.plan_end_date {
if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
let now = Utc::now();
if end > now {
let duration = end.signed_duration_since(now);
return Some(duration.num_days());
}
}
}
None
}
pub fn is_stale_for_writes(&self) -> bool {
if self.cached_at == 0 {
return true;
}
let now = Utc::now().timestamp();
now - self.cached_at > WRITE_OP_REFRESH_SECS
}
}
fn cache_path(config: &CliConfig) -> PathBuf {
config.cache_dir.join(ORG_TICKET_FILE)
}
pub fn load(config: &CliConfig) -> Result<CachedOrgTicket> {
let path = cache_path(config);
let data = fs::read(&path).with_context(|| "no cached org ticket available")?;
let cached: CachedOrgTicket = serde_json::from_slice(&data)
.with_context(|| format!("failed to parse cached org ticket: {}", path.display()))?;
Ok(cached)
}
pub fn store(config: &CliConfig, response: &OrgTicketResponse) -> Result<()> {
let path = cache_path(config);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create org ticket cache directory: {}",
parent.display()
)
})?;
}
let cached = CachedOrgTicket {
ticket: response.ticket.clone(),
plan_id: response.plan.id.clone(),
plan_name: response.plan.name.clone(),
org_id: response.organisation.id.clone(),
org_name: response.organisation.name.clone(),
total_storage_bytes: response.organisation.total_storage_bytes,
subscription_status: response.subscription.status.clone(),
plan_start_date: response.subscription.plan_start_date.clone(),
current_period_end: response.subscription.current_period_end.clone(),
plan_end_date: response.subscription.plan_end_date.clone(),
cached_at: Utc::now().timestamp(),
};
let tmp = path.with_extension("json.tmp");
let data = serde_json::to_vec_pretty(&cached)?;
fs::write(&tmp, data)
.with_context(|| format!("failed to write org ticket cache file: {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| {
format!(
"failed to atomically persist org ticket cache file: {}",
path.display()
)
})?;
Ok(())
}
pub fn clear(config: &CliConfig) -> Result<()> {
let path = cache_path(config);
if path.exists() {
fs::remove_file(&path).with_context(|| {
format!("failed to remove org ticket cache file: {}", path.display())
})?;
}
Ok(())
}
pub fn get_or_refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
if let Ok(cached) = load(config) {
if !cached.is_expired() {
return Ok(cached);
}
log::debug!("Cached org ticket expired, refreshing...");
}
refresh(config)
}
pub fn refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
log::debug!("Fetching org ticket from API...");
let response = fetch_org_ticket(config)?;
store(config, &response)?;
load(config)
}
pub fn get_optional(config: &CliConfig) -> Option<CachedOrgTicket> {
if config.api_key.is_none() {
log::debug!("No API key set, using free tier limits");
return None;
}
let needs_fetch = match load(config) {
Ok(cached) => cached.is_expired(),
Err(_) => true,
};
if needs_fetch {
log::debug!("Auto-syncing plan ticket from dashboard...");
match refresh(config) {
Ok(ticket) => {
log::info!(
"Plan synced: {} ({})",
ticket.plan_name,
format_capacity(ticket.capacity_bytes())
);
return Some(ticket);
}
Err(err) => {
let err_str = err.to_string();
if err_str.contains("Invalid API key") || err_str.contains("401") {
log::debug!("API key invalid, using free tier limits");
} else {
log::warn!("Failed to sync plan (using free tier limits): {}", err);
}
return None;
}
}
}
match load(config) {
Ok(ticket) => Some(ticket),
Err(_) => None,
}
}
fn format_capacity(bytes: u64) -> String {
if bytes >= 1024 * 1024 * 1024 * 1024 {
format!(
"{:.1} TB",
bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0)
)
} else if bytes >= 1024 * 1024 * 1024 {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
} else if bytes >= 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
pub fn get_fresh_for_writes(config: &CliConfig) -> Option<CachedOrgTicket> {
if config.api_key.is_none() {
return None;
}
let needs_refresh = match load(config) {
Ok(cached) => {
if cached.is_expired() {
true
} else {
let status = cached.subscription_status.as_str();
if status == "active" || status == "trialing" {
cached.is_stale_for_writes()
} else {
log::debug!(
"Non-active status '{}', refreshing to check for reactivation",
status
);
true
}
}
}
Err(_) => true,
};
if needs_refresh {
log::debug!("Refreshing ticket for write operation...");
match refresh(config) {
Ok(ticket) => return Some(ticket),
Err(err) => {
let err_str = err.to_string();
if err_str.contains("Invalid API key") || err_str.contains("401") {
log::debug!("API key invalid, using free tier limits");
} else {
log::warn!("Failed to refresh ticket: {}", err);
}
return None;
}
}
}
load(config).ok()
}