use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
pub struct CreditState {
balances: HashMap<String, i32>,
path: PathBuf,
}
pub type CreditStore = Arc<Mutex<CreditState>>;
pub const FREE_TIER_CREDITS: i32 = 3;
const DEFAULT_DB_PATH: &str = "./credits.json";
pub fn new_store() -> CreditStore {
let path = std::env::var("CREDITS_DB_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_DB_PATH));
let balances = match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str::<HashMap<String, i32>>(&s) {
Ok(map) => {
info!(
"Credits: loaded {} device balances from {}",
map.len(),
path.display()
);
map
}
Err(e) => {
warn!(
"Credits: {} is corrupt ({}), starting empty — back up the file before any write",
path.display(),
e
);
HashMap::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
info!(
"Credits: no existing snapshot at {} — starting fresh",
path.display()
);
HashMap::new()
}
Err(e) => {
warn!(
"Credits: could not read {} ({}) — starting empty",
path.display(),
e
);
HashMap::new()
}
};
Arc::new(Mutex::new(CreditState { balances, path }))
}
fn persist(state: &CreditState) {
let json = match serde_json::to_string(&state.balances) {
Ok(s) => s,
Err(e) => {
error!("Credits: serialise failed ({}); skipping persist", e);
return;
}
};
let mut tmp = state.path.clone();
let ext = format!(
"{}.tmp",
tmp.extension().and_then(|s| s.to_str()).unwrap_or("json")
);
tmp.set_extension(ext);
if let Err(e) = std::fs::write(&tmp, json) {
error!(
"Credits: write to {} failed ({}); in-memory state is still authoritative",
tmp.display(),
e
);
return;
}
if let Err(e) = std::fs::rename(&tmp, &state.path) {
error!(
"Credits: rename {} → {} failed ({})",
tmp.display(),
state.path.display(),
e
);
}
}
pub async fn balance(store: &CreditStore, device_id: &str) -> i32 {
let mut s = store.lock().await;
let was_new = !s.balances.contains_key(device_id);
let bal = *s.balances.entry(device_id.to_string()).or_insert_with(|| {
info!(
"New device {} — seeded with {} free credits",
short_id(device_id),
FREE_TIER_CREDITS
);
FREE_TIER_CREDITS
});
if was_new {
persist(&s);
}
bal
}
pub async fn reserve(store: &CreditStore, device_id: &str) -> Result<i32, ()> {
let mut s = store.lock().await;
{
let bal = s.balances.entry(device_id.to_string()).or_insert_with(|| {
info!(
"New device {} — seeded with {} free credits",
short_id(device_id),
FREE_TIER_CREDITS
);
FREE_TIER_CREDITS
});
if *bal <= 0 {
return Err(());
}
*bal -= 1;
info!(
"Reserved 1 credit for device {} — balance now {}",
short_id(device_id),
*bal
);
}
let new = *s.balances.get(device_id).unwrap_or(&0);
persist(&s);
Ok(new)
}
pub async fn refund(store: &CreditStore, device_id: &str) {
let mut s = store.lock().await;
{
let bal = s.balances.entry(device_id.to_string()).or_insert(0);
*bal += 1;
info!(
"Refunded 1 credit to device {} — balance now {}",
short_id(device_id),
*bal
);
}
persist(&s);
}
pub async fn add(store: &CreditStore, device_id: &str, amount: i32) -> i32 {
let mut s = store.lock().await;
let new = {
let bal = s.balances.entry(device_id.to_string()).or_insert(0);
*bal += amount;
info!(
"Added {} credits to device {} — balance now {}",
amount,
short_id(device_id),
*bal
);
*bal
};
persist(&s);
new
}
pub fn is_valid_device_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn short_id(s: &str) -> String {
if s.len() <= 8 {
s.to_string()
} else {
format!("{}…", &s[..8])
}
}