use crate::{config, gitcmd};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Record {
pub root: String,
#[serde(default)]
pub tip: String,
#[serde(default)]
pub confirmed: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
pub type Store = BTreeMap<String, Record>;
fn path() -> Result<PathBuf> {
Ok(config::home()?.join(".confer").join("known_hubs.json"))
}
fn lock_path() -> Result<PathBuf> {
Ok(config::home()?.join(".confer").join("known_hubs.lock"))
}
pub fn load() -> Store {
path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_locked(p: &Path, store: &Store) -> Result<()> {
let tmp = p.with_extension("tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(store)?)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, p)?; Ok(())
}
pub fn update_with<T>(f: impl FnOnce(&mut Store) -> Result<T>) -> Result<T> {
let p = path()?;
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
let _guard = config::state_lock(&lock_path()?)
.ok_or_else(|| anyhow!("could not lock known_hubs (another confer is writing it) — try again"))?;
let mut store = load();
let out = f(&mut store)?;
write_locked(&p, &store)?;
Ok(out)
}
pub fn get(name: &str) -> Option<Record> {
load().get(name).cloned()
}
#[derive(Debug)]
pub enum Verdict {
FirstSight { root: String, tip: String },
Match { new_tip: String },
RootMismatch { pinned: String, got: String },
TipUnreachable { pinned_tip: String },
NotVerifiable(String),
}
pub fn verify(name: &str, root_dir: &Path) -> Verdict {
let root = match config::hub_root_strict(root_dir) {
Ok(config::HubRoot::Commit(sha)) => sha,
Ok(config::HubRoot::NoCommits) => return Verdict::NotVerifiable("hub has no commits yet".into()),
Err(e) => return Verdict::NotVerifiable(e.to_string()),
};
let head = match gitcmd::output(root_dir, &["rev-parse", "HEAD"]) {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => return Verdict::NotVerifiable("cannot resolve HEAD".into()),
};
match load().get(name) {
None => Verdict::FirstSight { root, tip: head },
Some(rec) => {
if rec.root != root {
return Verdict::RootMismatch { pinned: rec.root.clone(), got: root };
}
if rec.tip.is_empty() {
return Verdict::NotVerifiable(
"pin has no confirmed-good tip — re-confirm with `confer hub repin`".into(),
);
}
if gitcmd::is_ancestor(root_dir, &rec.tip, "HEAD") {
Verdict::Match { new_tip: head }
} else {
Verdict::TipUnreachable { pinned_tip: rec.tip.clone() }
}
}
}
}
pub fn record(name: &str, root: &str, tip: &str, confirmed: bool) -> Result<()> {
let (name, root, tip) = (name.to_string(), root.to_string(), tip.to_string());
update_with(move |store| {
let rec = store.entry(name).or_default();
rec.root = root;
rec.tip = tip;
rec.confirmed = confirmed;
Ok(())
})
}
pub fn advance_tip(name: &str, new_tip: &str) {
let (name, new_tip) = (name.to_string(), new_tip.to_string());
let _ = update_with(move |store| {
if let Some(rec) = store.get_mut(&name) {
if !new_tip.is_empty() {
rec.tip = new_tip;
}
}
Ok(())
});
}
pub fn prune(keep: &BTreeSet<String>) -> Result<Vec<String>> {
update_with(|store| {
let gone: Vec<String> = store.keys().filter(|k| !keep.contains(*k)).cloned().collect();
for g in &gone {
store.remove(g);
}
Ok(gone)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_round_trips_and_tolerates_unknown_fields() {
let raw = r#"{"codeshrew/agent-coord":{"root":"abc","tip":"def","confirmed":true,"future":9}}"#;
let store: Store = serde_json::from_str(raw).unwrap();
let rec = &store["codeshrew/agent-coord"];
assert_eq!(rec.root, "abc");
assert_eq!(rec.tip, "def");
assert!(rec.confirmed);
assert!(rec.extra.contains_key("future"));
assert!(serde_json::to_string(&store).unwrap().contains("future"));
}
#[test]
fn prune_forgets_names_not_in_keep() {
let mut store: Store = BTreeMap::new();
store.insert("keep-me".into(), Record { root: "r".into(), ..Default::default() });
store.insert("drop-me".into(), Record { root: "r2".into(), ..Default::default() });
let keep: BTreeSet<String> = ["keep-me".to_string()].into_iter().collect();
let gone: Vec<String> = store.keys().filter(|k| !keep.contains(*k)).cloned().collect();
assert_eq!(gone, vec!["drop-me".to_string()]);
}
#[test]
fn missing_store_loads_empty() {
let store: Store = serde_json::from_str("").ok().unwrap_or_default();
assert!(store.is_empty());
}
}