Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ crossterm = "0.28"
ratatui = { version = "0.28", default-features = false, features = ["crossterm"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
redb = "2"
rmcp = { version = "1.7.0", features = ["server", "schemars", "transport-io"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jni = { version = "0.22.4", optional = true }
Expand All @@ -64,6 +65,7 @@ rand = "0.9"
self_update = { version = "0.41", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", "rustls"] }
sha2 = "0.11.0"
fs3 = "0.5.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std"] }

[dev-dependencies]
insta = "1.48"
Expand Down
4 changes: 4 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub struct Args {
pub list_accounts: bool,
pub doctor: bool,
pub init: bool,
pub mcp: bool,
pub vpn: bool,
pub auto_failover: bool,
pub no_cache: bool,
Expand Down Expand Up @@ -88,6 +89,7 @@ where
list_accounts: false,
doctor: false,
init: false,
mcp: false,
vpn: false,
auto_failover: true,
no_cache: false,
Expand Down Expand Up @@ -122,6 +124,7 @@ where
"--list-accounts" => parsed.list_accounts = true,
"--doctor" => parsed.doctor = true,
"--init" => parsed.init = true,
"--mcp" => parsed.mcp = true,
"--vpn" => parsed.vpn = true,
"--no-failover" => parsed.auto_failover = false,
"--no-cache" => parsed.no_cache = true,
Expand Down Expand Up @@ -320,6 +323,7 @@ OPTIONS:
--list-accounts List available account profiles
--doctor Run system diagnostics
--init Interactive setup wizard
--mcp Start MCP server on stdio
--trend Show 30-day usage trends (requires saved snapshots)
--days <N> Days of trend history to show [default: 30]

Expand Down
55 changes: 54 additions & 1 deletion src/cli/cache.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
use redb::{Database, TableDefinition};
use redb::{Database, ReadableTable, TableDefinition};
use serde_json::Value;
use std::path::PathBuf;
use std::time::{Duration, Instant};

const TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("api_cache");
const DEFAULT_TTL_SECS: u64 = 30;

pub struct CacheEntry {
pub cache_key: String,
pub api_base: String,
pub cached_at: String,
pub age_secs: u64,
pub payload: Value,
}

pub struct CacheStore {
db: Database,
ttl: Duration,
Expand Down Expand Up @@ -112,6 +120,51 @@ impl CacheStore {
pub fn ttl(&self) -> Duration {
self.ttl
}

pub fn entries(&self) -> Result<Vec<CacheEntry>, String> {
let tx = self
.db
.begin_read()
.map_err(|e| format!("cache read tx failed: {e}"))?;
let table = tx
.open_table(TABLE)
.map_err(|e| format!("cache table open failed: {e}"))?;
let now_secs = std::time::UNIX_EPOCH
.elapsed()
.map(|d| d.as_secs())
.unwrap_or(0);
let mut entries = Vec::new();
for item in table
.iter()
.map_err(|e| format!("cache iteration failed: {e}"))?
{
let (key, value) = item.map_err(|e| format!("cache row read failed: {e}"))?;
let cache_key = std::str::from_utf8(key.value())
.map_err(|e| format!("cache key is invalid UTF-8: {e}"))?
.to_string();
let parsed: Value = serde_json::from_slice(value.value())
.map_err(|e| format!("cache value is invalid JSON: {e}"))?;
let cached_at = parsed
.get("cached_at")
.and_then(Value::as_str)
.unwrap_or("0")
.to_string();
let cached_at_secs = cached_at.parse::<u64>().unwrap_or(0);
let payload = parsed.get("payload").cloned().unwrap_or(Value::Null);
let api_base = cache_key
.split_once('|')
.map(|(_, api_base)| api_base.to_string())
.unwrap_or_default();
entries.push(CacheEntry {
cache_key,
api_base,
cached_at,
age_secs: now_secs.saturating_sub(cached_at_secs),
payload,
});
}
Ok(entries)
}
}

fn cache_key(api_key: &str, api_base: &str) -> String {
Expand Down
Loading
Loading