use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use bitrouter_config::AgentConfig;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstallRecord {
pub id: String,
pub version: String,
pub method: InstallMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_binary_path: Option<PathBuf>,
pub installed_at: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum InstallMethod {
Npx,
Uvx,
Binary,
}
impl fmt::Display for InstallMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Npx => "npx",
Self::Uvx => "uvx",
Self::Binary => "binary",
})
}
}
pub fn now_unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub async fn load_state(state_file: &Path) -> Result<Vec<InstallRecord>, String> {
match tokio::fs::read_to_string(state_file).await {
Ok(raw) => match serde_json::from_str::<Vec<InstallRecord>>(&raw) {
Ok(records) => Ok(records),
Err(e) => {
tracing::warn!(path = %state_file.display(), error = %e, "corrupt install state — starting fresh");
Ok(Vec::new())
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("failed to read {}: {e}", state_file.display())),
}
}
pub async fn save_state(state_file: &Path, records: &[InstallRecord]) -> Result<(), String> {
if let Some(parent) = state_file.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
let json = serde_json::to_vec_pretty(records)
.map_err(|e| format!("failed to serialise install state: {e}"))?;
let tmp = state_file.with_extension("json.tmp");
tokio::fs::write(&tmp, &json)
.await
.map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
tokio::fs::rename(&tmp, state_file).await.map_err(|e| {
format!(
"failed to rename {} → {}: {e}",
tmp.display(),
state_file.display()
)
})
}
pub async fn upsert_record(state_file: &Path, record: InstallRecord) -> Result<(), String> {
let mut records = load_state(state_file).await?;
if let Some(slot) = records.iter_mut().find(|r| r.id == record.id) {
*slot = record;
} else {
records.push(record);
}
save_state(state_file, &records).await
}
pub async fn remove_record(state_file: &Path, agent_id: &str) -> Result<(), String> {
let mut records = load_state(state_file).await?;
let before = records.len();
records.retain(|r| r.id != agent_id);
if records.len() == before {
return Ok(());
}
save_state(state_file, &records).await
}
pub async fn find_record(
state_file: &Path,
agent_id: &str,
) -> Result<Option<InstallRecord>, String> {
let records = load_state(state_file).await?;
Ok(records.into_iter().find(|r| r.id == agent_id))
}
pub fn load_state_sync(state_file: &Path) -> Vec<InstallRecord> {
let Ok(raw) = std::fs::read_to_string(state_file) else {
return Vec::new();
};
match serde_json::from_str(&raw) {
Ok(records) => records,
Err(e) => {
tracing::warn!(
path = %state_file.display(),
error = %e,
"corrupt install state (sync read) — starting fresh"
);
Vec::new()
}
}
}
pub fn overlay_install_state_sync(agents: &mut HashMap<String, AgentConfig>, state_file: &Path) {
for record in load_state_sync(state_file) {
if let (InstallMethod::Binary, Some(path)) =
(record.method, record.resolved_binary_path.as_ref())
&& let Some(cfg) = agents.get_mut(&record.id)
{
cfg.binary = path.to_string_lossy().into_owned();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn rec(id: &str) -> InstallRecord {
InstallRecord {
id: id.to_owned(),
version: "1.0.0".to_owned(),
method: InstallMethod::Npx,
resolved_binary_path: None,
installed_at: 1_700_000_000,
}
}
#[tokio::test]
async fn load_missing_returns_empty() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let path = dir.path().join("state.json");
let records = load_state(&path).await?;
assert!(records.is_empty());
Ok(())
}
#[tokio::test]
async fn upsert_roundtrip() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let path = dir.path().join("state.json");
upsert_record(&path, rec("alpha")).await?;
upsert_record(&path, rec("beta")).await?;
let records = load_state(&path).await?;
assert_eq!(records.len(), 2);
let mut updated = rec("alpha");
updated.version = "2.0.0".to_owned();
upsert_record(&path, updated).await?;
let records = load_state(&path).await?;
assert_eq!(records.len(), 2);
let alpha = records
.iter()
.find(|r| r.id == "alpha")
.ok_or("missing alpha")?;
assert_eq!(alpha.version, "2.0.0");
Ok(())
}
#[tokio::test]
async fn remove_is_idempotent() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let path = dir.path().join("state.json");
upsert_record(&path, rec("alpha")).await?;
remove_record(&path, "alpha").await?;
remove_record(&path, "alpha").await?;
let records = load_state(&path).await?;
assert!(records.is_empty());
Ok(())
}
#[tokio::test]
async fn corrupt_file_recovers_as_empty() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let path = dir.path().join("state.json");
tokio::fs::write(&path, b"this is not json")
.await
.map_err(|e| e.to_string())?;
let records = load_state(&path).await?;
assert!(records.is_empty());
upsert_record(&path, rec("gamma")).await?;
let records = load_state(&path).await?;
assert_eq!(records.len(), 1);
Ok(())
}
#[tokio::test]
async fn save_creates_parent_dir() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let path = dir.path().join("nested").join("agents").join("state.json");
save_state(&path, &[rec("omega")]).await?;
assert!(path.exists());
Ok(())
}
}