use std::path::{Path, PathBuf};
use libsql::{params, Builder, Connection, Database as LibsqlDatabase};
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavingsTotal {
pub saved_tokens: u64,
pub calls: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavingsDay {
pub day: i64,
pub saved_tokens: u64,
pub calls: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AgentCostSummary {
pub agent: String,
pub cost_usd: f64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_write_tokens: u64,
pub cache_read_tokens: u64,
pub credits: Option<u64>,
pub turns: u64,
}
pub struct GlobalDb {
conn: Connection,
_db: LibsqlDatabase,
}
pub fn global_db_path() -> Option<PathBuf> {
crate::agents::home_dir().map(|h| h.join(".tokensave").join("global.db"))
}
pub fn normalize_project_key(path: impl AsRef<Path>) -> String {
let path = path.as_ref();
let resolved = path
.canonicalize()
.unwrap_or_else(|_| lexical_absolute(path));
normalize_key_string(&resolved.to_string_lossy())
}
fn normalize_key_string(path: &str) -> String {
let mut s = path.to_string();
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
s = format!(r"\\{rest}");
} else if let Some(rest) = s.strip_prefix(r"\\?\") {
s = rest.to_string();
}
let bytes = s.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
s.replace_range(..1, &s[..1].to_ascii_uppercase());
}
trim_trailing_separators(&mut s);
s
}
fn lexical_absolute(path: &Path) -> PathBuf {
use std::path::Component;
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
let mut out = PathBuf::new();
for component in joined.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
fn trim_trailing_separators(s: &mut String) {
let root_len =
if s.len() >= 3 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1] == b':' {
3
} else {
1
};
while s.len() > root_len && (s.ends_with('/') || s.ends_with('\\')) {
s.pop();
}
}
fn parent_key(key: &str) -> Option<&str> {
let cut = key.rfind(['/', '\\'])?;
let parent = &key[..cut];
if parent.is_empty() || parent.ends_with(':') {
return None;
}
Some(parent)
}
pub const MAX_SIBLING_PROJECTS: usize = 5;
pub fn sibling_project_keys(served: &str, all: &[String]) -> Vec<String> {
let served = normalize_key_string(served);
let Some(parent) = parent_key(&served) else {
return Vec::new();
};
let mut siblings: Vec<String> = all
.iter()
.map(|path| normalize_key_string(path))
.filter(|key| *key != served && parent_key(key) == Some(parent))
.collect();
siblings.sort();
siblings.dedup();
siblings.truncate(MAX_SIBLING_PROJECTS);
siblings
}
impl GlobalDb {
pub async fn sibling_projects(&self, served_root: &Path) -> Vec<String> {
let served = normalize_project_key(served_root);
sibling_project_keys(&served, &self.list_project_paths().await)
}
pub async fn open_at(db_path: &std::path::Path) -> Option<Self> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok()?;
}
let db = Builder::new_local(db_path).build().await.ok()?;
let conn = db.connect().ok()?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;",
)
.await
.ok()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS projects (
path TEXT PRIMARY KEY,
tokens_saved INTEGER NOT NULL DEFAULT 0
)",
)
.await
.ok()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS turns (
message_id TEXT PRIMARY KEY,
project_hash TEXT NOT NULL,
session_id TEXT NOT NULL,
model TEXT NOT NULL,
timestamp INTEGER NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL,
category TEXT NOT NULL,
tool_names TEXT NOT NULL DEFAULT '',
agent TEXT NOT NULL DEFAULT 'claude',
credits INTEGER
);
CREATE INDEX IF NOT EXISTS idx_turns_timestamp ON turns(timestamp);
CREATE INDEX IF NOT EXISTS idx_turns_project ON turns(project_hash);
CREATE INDEX IF NOT EXISTS idx_turns_model ON turns(model);
CREATE TABLE IF NOT EXISTS parse_offsets (
file_path TEXT PRIMARY KEY,
byte_offset INTEGER NOT NULL,
mtime INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS savings_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
project_path TEXT NOT NULL,
tool_name TEXT NOT NULL,
before_tokens INTEGER NOT NULL,
after_tokens INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_savings_ledger_ts ON savings_ledger(ts);
CREATE INDEX IF NOT EXISTS idx_savings_ledger_project ON savings_ledger(project_path)",
)
.await
.ok()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
)
.await
.ok()?;
Self::migrate_turns_columns(&conn).await;
Self::migrate_project_paths(&conn).await;
Some(Self { conn, _db: db })
}
pub async fn open() -> Option<Self> {
let db_path = global_db_path()?;
Self::open_at(&db_path).await
}
async fn migrate_turns_columns(conn: &Connection) {
let Ok(mut rows) = conn.query("PRAGMA table_info(turns)", ()).await else {
return;
};
let mut has_agent = false;
let mut has_credits = false;
while let Ok(Some(row)) = rows.next().await {
if let Ok(name) = row.get::<String>(1) {
match name.as_str() {
"agent" => has_agent = true,
"credits" => has_credits = true,
_ => {}
}
}
}
if !has_agent {
let _ = conn
.execute(
"ALTER TABLE turns ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'",
(),
)
.await;
}
if !has_credits {
let _ = conn
.execute("ALTER TABLE turns ADD COLUMN credits INTEGER", ())
.await;
}
}
async fn migrate_project_paths(conn: &Connection) {
if let Ok(mut flag) = conn
.query(
"SELECT 1 FROM meta WHERE key = 'projects_path_normalized'",
(),
)
.await
{
if matches!(flag.next().await, Ok(Some(_))) {
return;
}
}
let Ok(mut rows) = conn
.query("SELECT path, tokens_saved FROM projects", ())
.await
else {
return;
};
let mut existing: Vec<(String, i64)> = Vec::new();
while let Ok(Some(row)) = rows.next().await {
if let (Ok(path), Ok(tokens)) = (row.get::<String>(0), row.get::<i64>(1)) {
existing.push((path, tokens));
}
}
let mut remapped: Vec<(String, String)> = Vec::new();
let mut merged: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
let mut dropped: Vec<String> = Vec::new();
for (path, tokens) in existing {
if !Path::new(&path).is_absolute() {
dropped.push(path);
continue;
}
let key = normalize_project_key(&path);
if key != path {
remapped.push((path, key.clone()));
}
*merged.entry(key).or_insert(0) += tokens;
}
if !remapped.is_empty() || !dropped.is_empty() {
if conn.execute("DELETE FROM projects", ()).await.is_err() {
return;
}
for (key, tokens) in &merged {
let _ = conn
.execute(
"INSERT INTO projects (path, tokens_saved) VALUES (?1, ?2)",
params![key.as_str(), *tokens],
)
.await;
}
for (old, new) in &remapped {
let _ = conn
.execute(
"UPDATE savings_ledger SET project_path = ?1 WHERE project_path = ?2",
params![new.as_str(), old.as_str()],
)
.await;
}
}
let _ = conn
.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('projects_path_normalized', '1')",
(),
)
.await;
}
pub async fn upsert(&self, project_path: &Path, tokens_saved: u64) {
let path_str = normalize_project_key(project_path);
let _ = self
.conn
.execute(
"INSERT INTO projects (path, tokens_saved) VALUES (?1, ?2)
ON CONFLICT(path) DO UPDATE SET tokens_saved = ?2",
params![path_str, tokens_saved as i64],
)
.await;
}
pub async fn get_project_tokens(&self, project_path: &Path) -> u64 {
let path_str = normalize_project_key(project_path);
let Ok(mut rows) = self
.conn
.query(
"SELECT tokens_saved FROM projects WHERE path = ?1",
params![path_str],
)
.await
else {
return 0;
};
match rows.next().await {
Ok(Some(row)) => row.get::<i64>(0).unwrap_or(0) as u64,
_ => 0,
}
}
pub async fn global_tokens_saved(&self) -> Option<u64> {
let mut rows = self
.conn
.query("SELECT COALESCE(SUM(tokens_saved), 0) FROM projects", ())
.await
.ok()?;
let row = rows.next().await.ok()??;
let total: i64 = row.get(0).ok()?;
Some(total as u64)
}
pub async fn record_savings(
&self,
project_path: &str,
tool_name: &str,
before_tokens: u64,
after_tokens: u64,
ts: i64,
) {
let project_path = normalize_project_key(project_path);
let result = self
.conn
.execute(
"INSERT INTO savings_ledger (ts, project_path, tool_name, before_tokens, after_tokens) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![ts, project_path, tool_name, before_tokens as i64, after_tokens as i64],
)
.await;
if let Err(e) = result {
eprintln!("[tokensave] savings_ledger insert failed: {e}");
}
}
pub async fn sum_savings(&self, project: Option<&str>, since: i64) -> SavingsTotal {
let sql_with_project =
"SELECT COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), COUNT(*) \
FROM savings_ledger WHERE project_path = ?1 AND ts >= ?2";
let sql_all =
"SELECT COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), COUNT(*) \
FROM savings_ledger WHERE ts >= ?1";
let rows = match project.map(normalize_project_key) {
Some(p) => self.conn.query(sql_with_project, params![p, since]).await,
None => self.conn.query(sql_all, params![since]).await,
};
let Ok(mut rows) = rows else {
return SavingsTotal {
saved_tokens: 0,
calls: 0,
};
};
match rows.next().await {
Ok(Some(row)) => SavingsTotal {
saved_tokens: row.get::<i64>(0).unwrap_or(0).max(0) as u64,
calls: row.get::<i64>(1).unwrap_or(0).max(0) as u64,
},
_ => SavingsTotal {
saved_tokens: 0,
calls: 0,
},
}
}
pub async fn savings_history(&self, project: Option<&str>, since: i64) -> Vec<SavingsDay> {
let sql_with_project =
"SELECT (ts/86400)*86400 AS day, \
COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), \
COUNT(*) \
FROM savings_ledger WHERE project_path = ?1 AND ts >= ?2 \
GROUP BY day ORDER BY day DESC";
let sql_all =
"SELECT (ts/86400)*86400 AS day, \
COALESCE(SUM(CASE WHEN before_tokens > after_tokens THEN before_tokens - after_tokens ELSE 0 END), 0), \
COUNT(*) \
FROM savings_ledger WHERE ts >= ?1 \
GROUP BY day ORDER BY day DESC";
let rows = match project.map(normalize_project_key) {
Some(p) => self.conn.query(sql_with_project, params![p, since]).await,
None => self.conn.query(sql_all, params![since]).await,
};
let Ok(mut rows) = rows else {
return Vec::new();
};
let mut out = Vec::new();
while let Ok(Some(row)) = rows.next().await {
out.push(SavingsDay {
day: row.get::<i64>(0).unwrap_or(0),
saved_tokens: row.get::<i64>(1).unwrap_or(0).max(0) as u64,
calls: row.get::<i64>(2).unwrap_or(0).max(0) as u64,
});
}
out
}
pub async fn delete_project(&self, project_path: &Path) {
let path_str = normalize_project_key(project_path);
let _ = self
.conn
.execute("DELETE FROM projects WHERE path = ?1", params![path_str])
.await;
}
pub async fn delete_projects(&self, project_paths: &[String]) -> usize {
const CHUNK: usize = 256;
let mut total: usize = 0;
for chunk in project_paths.chunks(CHUNK) {
if chunk.is_empty() {
continue;
}
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"DELETE FROM projects WHERE path IN ({})",
placeholders.join(",")
);
let values: Vec<libsql::Value> = chunk
.iter()
.map(|p| libsql::Value::Text(p.clone()))
.collect();
if let Ok(n) = self.conn.execute(&sql, values).await {
total = total.saturating_add(n as usize);
}
}
total
}
pub async fn list_project_paths(&self) -> Vec<String> {
let Ok(mut rows) = self.conn.query("SELECT path FROM projects", ()).await else {
return Vec::new();
};
let mut paths = Vec::new();
while let Ok(Some(row)) = rows.next().await {
if let Ok(path) = row.get::<String>(0) {
paths.push(path);
}
}
paths
}
pub async fn insert_turn(&self, turn: &crate::types::CostTurn) -> bool {
self.conn
.execute(
"INSERT OR IGNORE INTO turns
(message_id, project_hash, session_id, model, timestamp,
input_tokens, output_tokens, cache_write_tokens, cache_read_tokens,
cost_usd, category, tool_names, agent, credits)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
turn.message_id.clone(),
turn.project_hash.clone(),
turn.session_id.clone(),
turn.model.clone(),
turn.timestamp as i64,
turn.input_tokens as i64,
turn.output_tokens as i64,
turn.cache_write_tokens as i64,
turn.cache_read_tokens as i64,
turn.cost_usd,
turn.category.clone(),
turn.tool_names.clone(),
turn.agent.clone(),
turn.credits.map(|c| c as i64),
],
)
.await
.is_ok_and(|n| n > 0)
}
pub async fn upsert_droid_turn(&self, turn: &crate::types::CostTurn) -> bool {
self.conn
.execute(
"INSERT INTO turns
(message_id, project_hash, session_id, model, timestamp,
input_tokens, output_tokens, cache_write_tokens, cache_read_tokens,
cost_usd, category, tool_names, agent, credits)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(message_id) DO UPDATE SET
project_hash = excluded.project_hash,
session_id = excluded.session_id,
model = excluded.model,
input_tokens = MAX(excluded.input_tokens, turns.input_tokens),
output_tokens = MAX(excluded.output_tokens, turns.output_tokens),
cache_write_tokens = MAX(excluded.cache_write_tokens, turns.cache_write_tokens),
cache_read_tokens = MAX(excluded.cache_read_tokens, turns.cache_read_tokens),
cost_usd = excluded.cost_usd,
category = excluded.category,
tool_names = excluded.tool_names,
agent = excluded.agent,
credits = COALESCE(excluded.credits, turns.credits)
WHERE
excluded.input_tokens >= turns.input_tokens
AND excluded.output_tokens >= turns.output_tokens
AND excluded.cache_write_tokens >= turns.cache_write_tokens
AND excluded.cache_read_tokens >= turns.cache_read_tokens
AND (excluded.credits IS NULL
OR turns.credits IS NULL
OR excluded.credits >= turns.credits)
AND (
excluded.input_tokens > turns.input_tokens
OR excluded.output_tokens > turns.output_tokens
OR excluded.cache_write_tokens > turns.cache_write_tokens
OR excluded.cache_read_tokens > turns.cache_read_tokens
OR (excluded.credits IS NOT NULL
AND (turns.credits IS NULL
OR excluded.credits > turns.credits))
)",
params![
turn.message_id.clone(),
turn.project_hash.clone(),
turn.session_id.clone(),
turn.model.clone(),
turn.timestamp as i64,
turn.input_tokens as i64,
turn.output_tokens as i64,
turn.cache_write_tokens as i64,
turn.cache_read_tokens as i64,
turn.cost_usd,
turn.category.clone(),
turn.tool_names.clone(),
turn.agent.clone(),
turn.credits.map(|c| c as i64),
],
)
.await
.is_ok_and(|n| n > 0)
}
pub async fn total_cost_since(&self, since: u64) -> Option<f64> {
let mut rows = self
.conn
.query(
"SELECT COALESCE(SUM(cost_usd), 0.0) FROM turns WHERE timestamp >= ?1",
params![since as i64],
)
.await
.ok()?;
let row = rows.next().await.ok()??;
Some(row.get::<f64>(0).unwrap_or(0.0))
}
pub async fn total_tokens_since(&self, since: u64) -> Option<u64> {
let mut rows = self
.conn
.query(
"SELECT COALESCE(SUM(input_tokens + output_tokens), 0) FROM turns WHERE timestamp >= ?1",
params![since as i64],
)
.await
.ok()?;
let row = rows.next().await.ok()??;
Some(row.get::<i64>(0).unwrap_or(0) as u64)
}
pub async fn token_breakdown_since(&self, since: u64) -> Option<(u64, u64, u64)> {
let mut rows = self
.conn
.query(
"SELECT COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0)
FROM turns WHERE timestamp >= ?1 AND agent = 'claude'",
params![since as i64],
)
.await
.ok()?;
let row = rows.next().await.ok()??;
Some((
row.get::<i64>(0).unwrap_or(0) as u64,
row.get::<i64>(1).unwrap_or(0) as u64,
row.get::<i64>(2).unwrap_or(0) as u64,
))
}
pub async fn cost_by_model_since(&self, since: u64) -> Vec<(String, f64, u64)> {
let Ok(mut rows) = self
.conn
.query(
"SELECT model, SUM(cost_usd), SUM(input_tokens + output_tokens)
FROM turns WHERE timestamp >= ?1 AND agent = 'claude'
GROUP BY model ORDER BY SUM(cost_usd) DESC",
params![since as i64],
)
.await
else {
return Vec::new();
};
let mut out = Vec::new();
while let Ok(Some(row)) = rows.next().await {
let model: String = row.get(0).unwrap_or_default();
let cost: f64 = row.get(1).unwrap_or(0.0);
let tokens: i64 = row.get(2).unwrap_or(0);
out.push((model, cost, tokens as u64));
}
out
}
pub async fn cost_by_category_since(&self, since: u64) -> Vec<(String, f64, u64)> {
let Ok(mut rows) = self
.conn
.query(
"SELECT category, SUM(cost_usd), COUNT(*)
FROM turns WHERE timestamp >= ?1 AND agent = 'claude'
GROUP BY category ORDER BY SUM(cost_usd) DESC",
params![since as i64],
)
.await
else {
return Vec::new();
};
let mut out = Vec::new();
while let Ok(Some(row)) = rows.next().await {
let cat: String = row.get(0).unwrap_or_default();
let cost: f64 = row.get(1).unwrap_or(0.0);
let count: i64 = row.get(2).unwrap_or(0);
out.push((cat, cost, count as u64));
}
out
}
pub async fn nav_turns_since(&self, since: u64) -> Vec<(String, u64)> {
let Ok(mut rows) = self
.conn
.query(
"SELECT tool_names, input_tokens FROM turns WHERE timestamp >= ?1 AND agent = 'claude'",
params![since as i64],
)
.await
else {
return Vec::new();
};
let mut out = Vec::new();
while let Ok(Some(row)) = rows.next().await {
let tool_names: String = row.get(0).unwrap_or_default();
let input_tokens: i64 = row.get(1).unwrap_or(0);
out.push((tool_names, input_tokens.max(0) as u64));
}
out
}
pub async fn cost_by_agent_since(&self, since: u64) -> Vec<AgentCostSummary> {
let Ok(mut rows) = self
.conn
.query(
"SELECT agent,
COALESCE(SUM(cost_usd), 0.0),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(cache_write_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0),
CASE WHEN COUNT(credits) = COUNT(*) THEN SUM(credits) ELSE NULL END,
COUNT(*)
FROM turns WHERE timestamp >= ?1
GROUP BY agent ORDER BY agent",
params![since as i64],
)
.await
else {
return Vec::new();
};
let mut out = Vec::new();
while let Ok(Some(row)) = rows.next().await {
let agent: String = row.get(0).unwrap_or_default();
let cost_usd: f64 = row.get(1).unwrap_or(0.0);
let input_tokens: i64 = row.get(2).unwrap_or(0);
let output_tokens: i64 = row.get(3).unwrap_or(0);
let cache_write_tokens: i64 = row.get(4).unwrap_or(0);
let cache_read_tokens: i64 = row.get(5).unwrap_or(0);
let credits: Option<i64> = row.get(6).ok();
let turns: i64 = row.get(7).unwrap_or(0);
out.push(AgentCostSummary {
agent,
cost_usd,
input_tokens: input_tokens.max(0) as u64,
output_tokens: output_tokens.max(0) as u64,
cache_write_tokens: cache_write_tokens.max(0) as u64,
cache_read_tokens: cache_read_tokens.max(0) as u64,
credits: credits.map(|c| c.max(0) as u64),
turns: turns.max(0) as u64,
});
}
out
}
pub async fn get_parse_offset(&self, path: &str) -> Option<(u64, u64)> {
let mut rows = self
.conn
.query(
"SELECT byte_offset, mtime FROM parse_offsets WHERE file_path = ?1",
params![path],
)
.await
.ok()?;
let row = rows.next().await.ok()??;
let offset: i64 = row.get(0).ok()?;
let mtime: i64 = row.get(1).ok()?;
Some((offset as u64, mtime as u64))
}
pub async fn set_parse_offset(&self, path: &str, offset: u64, mtime: u64) {
let _ = self
.conn
.execute(
"INSERT INTO parse_offsets (file_path, byte_offset, mtime) VALUES (?1, ?2, ?3)
ON CONFLICT(file_path) DO UPDATE SET byte_offset = ?2, mtime = ?3",
params![path, offset as i64, mtime as i64],
)
.await;
}
pub async fn checkpoint(&self) {
let _ = self
.conn
.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
.await;
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn dot_resolves_to_the_same_key_as_the_absolute_path() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(normalize_project_key("."), normalize_project_key(&cwd));
assert_eq!(normalize_project_key("./"), normalize_project_key(&cwd));
}
#[test]
fn parent_relative_path_resolves_to_the_parent_directory() {
let cwd = std::env::current_dir().unwrap();
let parent = cwd.parent().unwrap();
assert_eq!(normalize_project_key(".."), normalize_project_key(parent));
}
#[test]
fn trailing_separators_do_not_create_a_second_key() {
let cwd = std::env::current_dir().unwrap();
let with_slash = format!("{}/", cwd.display());
assert_eq!(
normalize_project_key(&with_slash),
normalize_project_key(&cwd)
);
}
#[test]
fn drive_letter_case_is_folded() {
assert_eq!(
normalize_key_string(r"d:\Work\ProjectA"),
normalize_key_string(r"D:\Work\ProjectA")
);
assert_eq!(
normalize_key_string(r"d:\Work\ProjectA"),
r"D:\Work\ProjectA"
);
}
#[test]
fn windows_verbatim_prefixes_are_stripped() {
assert_eq!(normalize_key_string(r"\\?\D:\Work\P"), r"D:\Work\P");
assert_eq!(normalize_key_string(r"\\?\UNC\srv\share"), r"\\srv\share");
}
#[test]
fn a_root_is_not_trimmed_away() {
let mut unix_root = "/".to_string();
trim_trailing_separators(&mut unix_root);
assert_eq!(unix_root, "/");
let mut drive_root = r"D:\".to_string();
trim_trailing_separators(&mut drive_root);
assert_eq!(drive_root, r"D:\");
}
#[test]
fn unresolvable_absolute_paths_keep_a_stable_key() {
let key = normalize_project_key("/definitely/not/here/../here");
assert_eq!(key, normalize_project_key("/definitely/not/here"));
}
#[tokio::test]
async fn relative_and_absolute_writes_share_one_row() {
let dir = tempfile::tempdir().unwrap();
let db = GlobalDb::open_at(&dir.path().join("global.db"))
.await
.unwrap();
let project = dir.path().join("proj");
std::fs::create_dir(&project).unwrap();
db.upsert(&project, 100).await;
db.upsert(&project.join("."), 250).await;
assert_eq!(db.list_project_paths().await.len(), 1);
assert_eq!(db.get_project_tokens(&project).await, 250);
}
#[tokio::test]
async fn migration_merges_duplicates_and_drops_relative_rows() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("global.db");
let project = dir.path().join("proj");
std::fs::create_dir(&project).unwrap();
let canonical = normalize_project_key(&project);
let trailing = format!("{canonical}/");
{
let db = GlobalDb::open_at(&db_path).await.unwrap();
db.conn
.execute_batch(
"DELETE FROM meta WHERE key = 'projects_path_normalized';
DELETE FROM projects;",
)
.await
.unwrap();
for (path, tokens) in [
(canonical.as_str(), 14_000_i64),
(trailing.as_str(), 12_000),
(".", 999),
] {
db.conn
.execute(
"INSERT INTO projects (path, tokens_saved) VALUES (?1, ?2)",
params![path, tokens],
)
.await
.unwrap();
}
db.conn
.execute(
"INSERT INTO savings_ledger (ts, project_path, tool_name, before_tokens, after_tokens) \
VALUES (42, ?1, 'tokensave_context', 1000, 100)",
params![trailing.as_str()],
)
.await
.unwrap();
}
let db = GlobalDb::open_at(&db_path).await.unwrap();
let paths = db.list_project_paths().await;
assert_eq!(paths, vec![canonical.clone()]);
assert_eq!(db.get_project_tokens(&project).await, 26_000);
assert_eq!(db.sum_savings(Some(&canonical), 0).await.calls, 1);
}
}