use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const BRANCH_META_FILENAME: &str = "branch-meta.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchEntry {
pub db_file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub created_at: String,
pub last_synced_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchMeta {
pub default_branch: String,
pub branches: HashMap<String, BranchEntry>,
}
impl BranchMeta {
pub fn new(default_branch: &str) -> Self {
let now = now_unix_str();
let mut branches = HashMap::new();
branches.insert(
default_branch.to_string(),
BranchEntry {
db_file: "tokensave.db".to_string(),
parent: None,
created_at: now.clone(),
last_synced_at: now,
},
);
Self {
default_branch: default_branch.to_string(),
branches,
}
}
pub fn add_branch(&mut self, name: &str, db_file: &str, parent: &str) {
let now = now_unix_str();
self.branches.insert(
name.to_string(),
BranchEntry {
db_file: db_file.to_string(),
parent: Some(parent.to_string()),
created_at: now.clone(),
last_synced_at: now,
},
);
}
pub fn remove_branch(&mut self, name: &str) -> Option<BranchEntry> {
if name == self.default_branch {
return None; }
self.branches.remove(name)
}
pub fn touch_synced(&mut self, name: &str) {
if let Some(entry) = self.branches.get_mut(name) {
entry.last_synced_at = now_unix_str();
}
}
pub fn remove_all_branches(&mut self) -> Vec<(String, BranchEntry)> {
let default = self.default_branch.clone();
let removed: Vec<(String, BranchEntry)> = self
.branches
.keys()
.filter(|name| *name != &default)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.filter_map(|name| self.branches.remove(&name).map(|e| (name, e)))
.collect();
removed
}
pub fn is_tracked(&self, name: &str) -> bool {
self.branches.contains_key(name)
}
}
pub fn load_branch_meta(tokensave_dir: &Path) -> Option<BranchMeta> {
let path = tokensave_dir.join(BRANCH_META_FILENAME);
let content = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&content) {
Ok(meta) => Some(meta),
Err(e) => {
eprintln!(
"warning: corrupt branch metadata at '{}': {e} — falling back to single-DB mode",
path.display()
);
None
}
}
}
pub fn save_branch_meta(tokensave_dir: &Path, meta: &BranchMeta) -> std::io::Result<()> {
use std::io::Write;
let path = tokensave_dir.join(BRANCH_META_FILENAME);
let json = serde_json::to_string_pretty(meta).map_err(std::io::Error::other)?;
let dir = path.parent().unwrap_or(tokensave_dir);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp = dir.join(format!(
".{BRANCH_META_FILENAME}.{}.{nanos}.tmp",
std::process::id()
));
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
pub fn ensure_branches_dir(tokensave_dir: &Path) -> std::io::Result<PathBuf> {
let dir = tokensave_dir.join("branches");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
fn now_unix_str() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{secs}")
}
pub fn format_timestamp(ts: &str) -> String {
let Ok(secs) = ts.parse::<u64>() else {
return ts.to_string();
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age = now.saturating_sub(secs);
if age < 60 {
"just now".to_string()
} else if age < 3600 {
format!("{}m ago", age / 60)
} else if age < 86400 {
format!("{}h {}m ago", age / 3600, (age % 3600) / 60)
} else {
format!("{}d ago", age / 86400)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn save_branch_meta_is_atomic_and_leaves_no_temp_file() {
let dir = tempfile::tempdir().unwrap();
let mut meta = BranchMeta::new("main");
meta.add_branch("feature/foo", "branches/feature_foo.db", "main");
save_branch_meta(dir.path(), &meta).unwrap();
let loaded = load_branch_meta(dir.path()).unwrap();
assert!(loaded.is_tracked("feature/foo"));
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(entries, vec![BRANCH_META_FILENAME.to_string()]);
meta.add_branch("feature/bar", "branches/feature_bar.db", "main");
save_branch_meta(dir.path(), &meta).unwrap();
let entries2: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(entries2, vec![BRANCH_META_FILENAME.to_string()]);
}
#[test]
fn new_meta_has_default_branch() {
let meta = BranchMeta::new("main");
assert_eq!(meta.default_branch, "main");
assert!(meta.is_tracked("main"));
assert_eq!(meta.branches["main"].db_file, "tokensave.db");
assert!(meta.branches["main"].parent.is_none());
}
#[test]
fn add_and_remove_branch() {
let mut meta = BranchMeta::new("main");
meta.add_branch("feature/foo", "branches/feature_foo.db", "main");
assert!(meta.is_tracked("feature/foo"));
assert_eq!(meta.branches["feature/foo"].parent.as_deref(), Some("main"));
let removed = meta.remove_branch("feature/foo");
assert!(removed.is_some());
assert!(!meta.is_tracked("feature/foo"));
}
#[test]
fn cannot_remove_default_branch() {
let mut meta = BranchMeta::new("main");
assert!(meta.remove_branch("main").is_none());
}
#[test]
fn roundtrip_json() {
let mut meta = BranchMeta::new("main");
meta.add_branch("feature/bar", "branches/feature_bar.db", "main");
let json = serde_json::to_string(&meta).unwrap();
let parsed: BranchMeta = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.default_branch, "main");
assert!(parsed.is_tracked("feature/bar"));
}
}