use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerEntry {
pub pid: u32,
pub started_at: u64,
pub project_path: String,
pub argv_path: Option<String>,
pub db_path: String,
pub version: String,
}
fn registry_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".tokensave").join("servers"))
}
fn entry_path(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("{pid}.json"))
}
fn start_times(pids: &[u32]) -> HashMap<u32, u64> {
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
let sys_pids: Vec<Pid> = pids
.iter()
.filter(|&&p| p != 0)
.map(|&p| Pid::from_u32(p))
.collect();
if sys_pids.is_empty() {
return HashMap::new();
}
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&sys_pids),
true,
ProcessRefreshKind::new(),
);
pids.iter()
.filter_map(|&pid| {
let proc = sys.process(Pid::from_u32(pid))?;
Some((pid, proc.start_time()))
})
.collect()
}
fn own_start_time(pid: u32) -> u64 {
start_times(&[pid]).get(&pid).copied().unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
}
fn read_all_with_paths(dir: &Path) -> Vec<(PathBuf, Option<ServerEntry>)> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.map(|path| {
let parsed = std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str::<ServerEntry>(&t).ok());
(path, parsed)
})
.collect()
}
#[cfg(test)]
fn read_all(dir: &Path) -> Vec<ServerEntry> {
read_all_with_paths(dir)
.into_iter()
.filter_map(|(_, e)| e)
.collect()
}
pub fn reap() -> Vec<ServerEntry> {
registry_dir().map(|d| reap_at(&d)).unwrap_or_default()
}
pub fn reap_at(dir: &Path) -> Vec<ServerEntry> {
let candidates = read_all_with_paths(dir);
let pids: Vec<u32> = candidates
.iter()
.filter_map(|(_, e)| e.as_ref().map(|e| e.pid))
.collect();
let live = start_times(&pids);
let mut alive = Vec::new();
for (path, parsed) in candidates {
let keep = parsed.as_ref().is_some_and(|e| {
live.get(&e.pid)
.is_some_and(|&now| now.abs_diff(e.started_at) <= 1)
});
if keep {
if let Some(e) = parsed {
alive.push(e);
}
} else {
let _ = std::fs::remove_file(&path);
}
}
alive.sort_by_key(|e| e.pid);
alive
}
pub fn register(project_path: &Path, db_path: &Path, argv_path: Option<&str>) {
if let Some(dir) = registry_dir() {
register_at(&dir, std::process::id(), project_path, db_path, argv_path);
}
}
pub fn register_at(
dir: &Path,
pid: u32,
project_path: &Path,
db_path: &Path,
argv_path: Option<&str>,
) {
reap_at(dir);
if std::fs::create_dir_all(dir).is_err() {
return;
}
let entry = ServerEntry {
pid,
started_at: own_start_time(pid),
project_path: project_path.to_string_lossy().into_owned(),
argv_path: argv_path.map(str::to_string),
db_path: db_path.to_string_lossy().into_owned(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
let Ok(json) = serde_json::to_string_pretty(&entry) else {
return;
};
let _ = std::fs::write(entry_path(dir, pid), json);
}
pub fn unregister() {
if let Some(dir) = registry_dir() {
let _ = std::fs::remove_file(entry_path(&dir, std::process::id()));
}
}
pub fn list() -> Vec<ServerEntry> {
reap()
}
pub fn list_at(dir: &Path) -> Vec<ServerEntry> {
reap_at(dir)
}
pub fn render(entries: &[ServerEntry]) -> String {
use std::fmt::Write as _;
if entries.is_empty() {
return "No tokensave servers running.\n".to_string();
}
let pid_w = entries
.iter()
.map(|e| e.pid.to_string().len())
.max()
.unwrap_or(3)
.max(3);
let ver_w = entries
.iter()
.map(|e| e.version.len())
.max()
.unwrap_or(7)
.max(7);
let mut out = format!(
"{:>pid_w$} {:>ver_w$} {}\n",
"PID",
"VERSION",
"PROJECT",
pid_w = pid_w,
ver_w = ver_w
);
for e in entries {
let _ = writeln!(
out,
"{:>pid_w$} {:>ver_w$} {}",
e.pid,
e.version,
e.project_path,
pid_w = pid_w,
ver_w = ver_w
);
let default_db = Path::new(&e.project_path)
.join(".tokensave")
.join("tokensave.db");
if Path::new(&e.db_path) != default_db {
let _ = writeln!(
out,
"{:>pid_w$} {:>ver_w$} └─ db: {}",
"",
"",
e.db_path,
pid_w = pid_w,
ver_w = ver_w
);
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn entry(pid: u32, started_at: u64, project: &str) -> ServerEntry {
ServerEntry {
pid,
started_at,
project_path: project.to_string(),
argv_path: None,
db_path: format!("{project}/.tokensave/tokensave.db"),
version: "9.9.9".to_string(),
}
}
fn write(dir: &Path, e: &ServerEntry) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(
entry_path(dir, e.pid),
serde_json::to_string_pretty(e).unwrap(),
)
.unwrap();
}
#[test]
fn an_entry_names_the_process_holding_a_database() {
let dir = tempfile::tempdir().unwrap();
let e = entry(18012, 1_700_000_000, "/proj/a");
write(dir.path(), &e);
let found: Vec<_> = read_all(dir.path())
.into_iter()
.filter(|x| x.db_path == "/proj/a/.tokensave/tokensave.db")
.collect();
assert_eq!(found.len(), 1);
assert_eq!(found[0].pid, 18012);
}
#[test]
fn a_server_launched_without_a_path_still_records_its_project() {
let dir = tempfile::tempdir().unwrap();
let mut bare = entry(46000, 1_700_000_000, "/proj/bare");
let mut explicit = entry(44224, 1_700_000_000, "/proj/explicit");
explicit.argv_path = Some("/proj/explicit".to_string());
write(dir.path(), &bare);
write(dir.path(), &explicit);
let all = read_all(dir.path());
assert_eq!(all.len(), 2);
assert!(all.iter().all(|e| !e.project_path.is_empty()));
bare.argv_path = None;
assert!(all.iter().any(|e| e.pid == 46000 && e.argv_path.is_none()));
assert!(all
.iter()
.any(|e| e.pid == 44224 && e.argv_path.as_deref() == Some("/proj/explicit")));
}
#[test]
fn a_reused_pid_does_not_pass_for_the_recorded_process() {
let own = std::process::id();
let real = own_start_time(own);
let stale = entry(own, real.saturating_sub(10_000), "/proj/gone");
let live = start_times(&[own]);
assert!(
live.contains_key(&own),
"this process must see itself as running"
);
assert!(
live[&own].abs_diff(stale.started_at) > 1,
"a start time 10,000s off must not be accepted as the same process"
);
}
#[test]
fn a_dead_process_is_reaped_on_read() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &entry(1, 42, "/proj/reused"));
write(dir.path(), &entry(4_294_967_294, 42, "/proj/gone"));
assert_eq!(read_all(dir.path()).len(), 2, "both are on disk to start");
let alive = reap_at(dir.path());
assert!(alive.is_empty(), "neither names a live server: {alive:?}");
assert!(
read_all(dir.path()).is_empty(),
"reaping removes the files, not just the returned rows"
);
}
#[test]
fn a_live_server_survives_a_reap() {
let dir = tempfile::tempdir().unwrap();
let own = std::process::id();
register_at(
dir.path(),
own,
Path::new("/proj/live"),
Path::new("/proj/live/.tokensave/tokensave.db"),
None,
);
write(dir.path(), &entry(4_294_967_294, 42, "/proj/gone"));
let alive = list_at(dir.path());
assert_eq!(alive.len(), 1, "got {alive:?}");
assert_eq!(alive[0].pid, own);
assert_eq!(alive[0].project_path, "/proj/live");
assert_eq!(alive[0].version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn re_registering_replaces_rather_than_duplicates() {
let dir = tempfile::tempdir().unwrap();
let own = std::process::id();
for project in ["/proj/first", "/proj/second"] {
register_at(
dir.path(),
own,
Path::new(project),
Path::new("/db"),
Some(project),
);
}
let all = read_all(dir.path());
assert_eq!(all.len(), 1);
assert_eq!(all[0].project_path, "/proj/second");
}
#[test]
fn an_unparseable_entry_is_not_mistaken_for_a_server() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(entry_path(dir.path(), 1), b"{ not json").unwrap();
assert!(read_all(dir.path()).is_empty());
assert!(reap_at(dir.path()).is_empty());
assert!(
!entry_path(dir.path(), 1).exists(),
"a file that cannot be parsed is removed rather than left to accumulate"
);
}
#[test]
fn an_empty_registry_says_so_rather_than_printing_a_bare_header() {
assert_eq!(render(&[]), "No tokensave servers running.\n");
}
#[test]
fn a_non_default_database_path_is_shown() {
let plain = entry(1, 0, "/proj/a");
let mut branched = entry(2, 0, "/proj/b");
branched.db_path = "/proj/b/.tokensave/branches/feature.db".to_string();
let out = render(&[plain, branched]);
assert!(
!out.contains("/proj/a/.tokensave/tokensave.db"),
"the default path is already implied by the project column"
);
assert!(out.contains("/proj/b/.tokensave/branches/feature.db"));
}
}