use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use crate::errors::{Result, TokenSaveError};
const CPU_SAMPLE_WINDOW: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSnapshot {
pub captured_at: u64,
pub tokensave_version: &'static str,
pub host_os: &'static str,
pub process: ProcessSnapshot,
pub database: DatabaseSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessSnapshot {
pub pid: u32,
pub rss_bytes: u64,
pub virtual_bytes: u64,
pub cpu_percent: f32,
pub uptime_secs: u64,
pub system_cpu_count: usize,
pub system_total_memory_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseSnapshot {
pub project_root: PathBuf,
pub db_path: PathBuf,
pub db_size_bytes: u64,
pub wal_size_bytes: u64,
pub shm_size_bytes: u64,
pub journal_mode: Option<String>,
pub source_total_bytes: u64,
pub node_count: u64,
pub edge_count: u64,
}
pub async fn collect(cg: &crate::tokensave::TokenSave) -> Result<RuntimeSnapshot> {
let process = sample_process();
let database = sample_database(cg).await?;
let captured_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Ok(RuntimeSnapshot {
captured_at,
tokensave_version: env!("CARGO_PKG_VERSION"),
host_os: std::env::consts::OS,
process,
database,
})
}
pub fn to_pretty_json(snap: &RuntimeSnapshot) -> String {
serde_json::to_string_pretty(snap).unwrap_or_default()
}
pub fn to_text_report(snap: &RuntimeSnapshot) -> String {
let p = &snap.process;
let d = &snap.database;
let pct_of_system_mem = if p.system_total_memory_bytes > 0 {
(p.rss_bytes as f64 / p.system_total_memory_bytes as f64) * 100.0
} else {
0.0
};
let bloat_ratio = if d.source_total_bytes > 0 {
d.db_size_bytes as f64 / d.source_total_bytes as f64
} else {
0.0
};
format!(
"tokensave {ver} runtime snapshot ({os})\n\
────────────────────────────────────────\n\
pid {pid}\n\
rss {rss} ({rss_pct:.2}% of system)\n\
virtual {vsz}\n\
cpu {cpu:.1}% (sampled over {win}ms, {ncpu} CPUs)\n\
uptime {up}s\n\
system memory {sysmem}\n\
\n\
db file {db}\n\
db size {dbsz}\n\
wal size {wal}\n\
shm size {shm}\n\
journal mode {jm}\n\
source indexed {src}\n\
db / source {ratio:.1}×\n\
nodes / edges {nodes} / {edges}\n\
",
ver = snap.tokensave_version,
os = snap.host_os,
pid = p.pid,
rss = bytes_human(p.rss_bytes),
rss_pct = pct_of_system_mem,
vsz = bytes_human(p.virtual_bytes),
cpu = p.cpu_percent,
win = CPU_SAMPLE_WINDOW.as_millis(),
ncpu = p.system_cpu_count,
up = p.uptime_secs,
sysmem = bytes_human(p.system_total_memory_bytes),
db = d.db_path.display(),
dbsz = bytes_human(d.db_size_bytes),
wal = bytes_human(d.wal_size_bytes),
shm = bytes_human(d.shm_size_bytes),
jm = d.journal_mode.as_deref().unwrap_or("(unknown)"),
src = bytes_human(d.source_total_bytes),
ratio = bloat_ratio,
nodes = d.node_count,
edges = d.edge_count,
)
}
fn sample_process() -> ProcessSnapshot {
let pid = Pid::from_u32(std::process::id());
let mut sys = System::new_with_specifics(
RefreshKind::new()
.with_processes(ProcessRefreshKind::new().with_cpu().with_memory())
.with_memory(sysinfo::MemoryRefreshKind::new().with_ram())
.with_cpu(sysinfo::CpuRefreshKind::new()),
);
std::thread::sleep(CPU_SAMPLE_WINDOW);
sys.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::new().with_cpu().with_memory(),
);
let proc = sys.process(pid);
let rss_bytes = proc.map(sysinfo::Process::memory).unwrap_or(0);
let virtual_bytes = proc.map(sysinfo::Process::virtual_memory).unwrap_or(0);
let cpu_percent = proc.map(sysinfo::Process::cpu_usage).unwrap_or(0.0);
let uptime_secs = proc.map(sysinfo::Process::run_time).unwrap_or(0);
ProcessSnapshot {
pid: std::process::id(),
rss_bytes,
virtual_bytes,
cpu_percent,
uptime_secs,
system_cpu_count: sys.cpus().len(),
system_total_memory_bytes: sys.total_memory(),
}
}
async fn sample_database(cg: &crate::tokensave::TokenSave) -> Result<DatabaseSnapshot> {
let project_root = cg.project_root().to_path_buf();
let db_path = cg.db_path().to_path_buf();
let db_size_bytes = file_size(&db_path);
let wal_size_bytes = file_size(&with_suffix(&db_path, "-wal"));
let shm_size_bytes = file_size(&with_suffix(&db_path, "-shm"));
let journal_mode = read_journal_mode(cg).await.ok();
let source_total_bytes = read_source_total_bytes(cg).await.unwrap_or(0);
let (node_count, edge_count) = read_graph_counts(cg).await.unwrap_or((0, 0));
Ok(DatabaseSnapshot {
project_root,
db_path,
db_size_bytes,
wal_size_bytes,
shm_size_bytes,
journal_mode,
source_total_bytes,
node_count,
edge_count,
})
}
fn file_size(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut s: std::ffi::OsString = path.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
async fn read_journal_mode(cg: &crate::tokensave::TokenSave) -> Result<String> {
let mut rows = cg
.db()
.conn()
.query("PRAGMA journal_mode", ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to read journal_mode: {e}"),
operation: "read_journal_mode".to_string(),
})?;
let row = rows
.next()
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to read journal_mode row: {e}"),
operation: "read_journal_mode".to_string(),
})?
.ok_or_else(|| TokenSaveError::Database {
message: "no journal_mode row returned".to_string(),
operation: "read_journal_mode".to_string(),
})?;
row.get::<String>(0).map_err(|e| TokenSaveError::Database {
message: format!("failed to decode journal_mode: {e}"),
operation: "read_journal_mode".to_string(),
})
}
async fn read_source_total_bytes(cg: &crate::tokensave::TokenSave) -> Result<u64> {
let mut rows = cg
.db()
.conn()
.query("SELECT COALESCE(SUM(size), 0) FROM files", ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to sum source bytes: {e}"),
operation: "read_source_total_bytes".to_string(),
})?;
let row = rows
.next()
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to read source-sum row: {e}"),
operation: "read_source_total_bytes".to_string(),
})?
.ok_or_else(|| TokenSaveError::Database {
message: "no source-sum row returned".to_string(),
operation: "read_source_total_bytes".to_string(),
})?;
let v: i64 = row.get(0).map_err(|e| TokenSaveError::Database {
message: format!("failed to decode source-sum: {e}"),
operation: "read_source_total_bytes".to_string(),
})?;
Ok(u64::try_from(v).unwrap_or(0))
}
async fn read_graph_counts(cg: &crate::tokensave::TokenSave) -> Result<(u64, u64)> {
let nodes = scalar_count(cg, "SELECT COUNT(*) FROM nodes").await?;
let edges = scalar_count(cg, "SELECT COUNT(*) FROM edges").await?;
Ok((nodes, edges))
}
async fn scalar_count(cg: &crate::tokensave::TokenSave, sql: &str) -> Result<u64> {
let mut rows = cg
.db()
.conn()
.query(sql, ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("scalar query failed: {e}"),
operation: "scalar_count".to_string(),
})?;
let row = rows
.next()
.await
.map_err(|e| TokenSaveError::Database {
message: format!("scalar row read failed: {e}"),
operation: "scalar_count".to_string(),
})?
.ok_or_else(|| TokenSaveError::Database {
message: "no scalar row".to_string(),
operation: "scalar_count".to_string(),
})?;
let v: i64 = row.get(0).map_err(|e| TokenSaveError::Database {
message: format!("scalar decode failed: {e}"),
operation: "scalar_count".to_string(),
})?;
Ok(u64::try_from(v).unwrap_or(0))
}
fn bytes_human(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if n >= GB {
format!("{:.1} GB", n as f64 / GB as f64)
} else if n >= MB {
format!("{:.1} MB", n as f64 / MB as f64)
} else if n >= KB {
format!("{:.1} KB", n as f64 / KB as f64)
} else {
format!("{n} B")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn bytes_human_formats_units() {
assert_eq!(bytes_human(0), "0 B");
assert_eq!(bytes_human(512), "512 B");
assert_eq!(bytes_human(2 * 1024), "2.0 KB");
assert_eq!(bytes_human(5 * 1024 * 1024), "5.0 MB");
assert_eq!(bytes_human(3 * 1024 * 1024 * 1024), "3.0 GB");
}
#[test]
fn with_suffix_appends_to_path() {
let p = Path::new("/tmp/x.db");
assert_eq!(with_suffix(p, "-wal"), Path::new("/tmp/x.db-wal"));
assert_eq!(with_suffix(p, "-shm"), Path::new("/tmp/x.db-shm"));
}
}