use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use aws_lc_rs::digest::{SHA256, digest};
use serde::{Deserialize, Serialize};
use yapi_ai::auth::lock::write_atomic;
use yapi_types::time::hex;
use super::hash::tree_hash;
use super::{ChestError, invalid, io};
use crate::settings::Scope;
#[derive(Debug, Default, Serialize, Deserialize)]
pub(super) struct Record {
#[serde(default)]
placed: BTreeMap<String, Placed>,
#[serde(skip)]
root: PathBuf,
#[serde(skip)]
manifest: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct Placed {
held: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
next: Option<String>,
#[serde(default)]
by: BTreeSet<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Held {
Unrecorded,
Owned,
Modified,
}
impl Record {
pub(super) fn path(state: &Path, root: &Path, scope: Scope) -> PathBuf {
if scope == Scope::Global {
return state.join("global.toml");
}
let digest = hex(digest(&SHA256, root.to_string_lossy().as_bytes()).as_ref());
state
.join("projects")
.join(format!("{}.toml", &digest[..16]))
}
pub(super) fn load(path: &Path, root: &Path, manifest: &str) -> Record {
let record: Option<Record> = std::fs::read_to_string(path)
.ok()
.and_then(|text| toml_edit::de::from_str(&text).ok());
Record {
root: root.to_path_buf(),
manifest: manifest.to_owned(),
..record.unwrap_or_default()
}
}
pub(super) fn save(&self, path: &Path) -> Result<(), ChestError> {
let parent = path.parent().unwrap_or(path);
std::fs::create_dir_all(parent).map_err(io(parent))?;
let text =
toml_edit::ser::to_string_pretty(self).map_err(|err| invalid(err.to_string()))?;
let text = format!("# What chest placed on this machine. Not for editing.\n{text}");
write_atomic(path, text.as_bytes(), 0o600).map_err(io(path))
}
fn key(&self, path: &Path) -> String {
let relative = path.strip_prefix(&self.root).unwrap_or(path);
super::slashed(relative)
}
pub(super) fn held(&self, path: &Path) -> Held {
let Some(placed) = self.placed.get(&self.key(path)) else {
return Held::Unrecorded;
};
let now = fingerprint(path);
let linked = now.as_ref().is_some_and(|now| now.starts_with("link:"));
let accepted = std::iter::once(&placed.held).chain(&placed.next);
let owned = accepted.into_iter().any(|recorded| {
now.as_ref() == Some(recorded)
|| !recorded.starts_with("link:")
&& linked
&& content_hash(path).as_ref() == Some(recorded)
});
if owned { Held::Owned } else { Held::Modified }
}
pub(super) fn holds(&self, path: &Path, hash: &str) -> bool {
let placed = self.placed.get(&self.key(path));
placed.is_some_and(|placed| {
placed.next.is_none() && (placed.held == hash || placed.held.starts_with("link:"))
})
}
pub(super) fn adopt(&mut self, path: &Path, hash: &str) {
let key = self.key(path);
let by = BTreeSet::from([self.manifest.clone()]);
let placed = Placed {
held: hash.to_owned(),
next: None,
by,
};
self.placed.insert(key, placed);
}
pub(super) fn expect(&mut self, path: &Path, hash: &str) {
let key = self.key(path);
let manifest = self.manifest.clone();
let placed = self.placed.entry(key).or_insert_with(|| Placed {
held: hash.to_owned(),
..Placed::default()
});
if placed.held != hash {
placed.next = Some(hash.to_owned());
}
placed.by.insert(manifest);
}
pub(super) fn noted(&mut self, path: &Path, hash: &str) {
let key = self.key(path);
let held = match path.symlink_metadata() {
Ok(metadata) if metadata.is_symlink() => fingerprint(path),
Ok(_) => Some(hash.to_owned()),
Err(_) => None,
};
match (held, self.placed.get_mut(&key)) {
(Some(held), Some(placed)) => {
placed.held = held;
placed.next = None;
}
(Some(held), None) => self.adopt(path, &held),
(None, _) => {
self.placed.remove(&key);
}
}
}
pub(super) fn get(&self, path: &Path) -> Option<Placed> {
self.placed.get(&self.key(path)).cloned()
}
pub(super) fn restore(&mut self, path: &Path, placed: Option<Placed>) {
let key = self.key(path);
match placed {
Some(placed) => self.placed.insert(key, placed),
None => self.placed.remove(&key),
};
}
pub(super) fn forget(&mut self, path: &Path) {
self.placed.remove(&self.key(path));
}
pub(super) fn shared(&self, path: &Path) -> bool {
let placed = self.placed.get(&self.key(path));
placed.is_some_and(|placed| placed.by.iter().any(|by| *by != self.manifest))
}
pub(super) fn release(&mut self, path: &Path) {
let key = self.key(path);
if let Some(placed) = self.placed.get_mut(&key) {
placed.by.remove(&self.manifest);
if placed.by.is_empty() {
self.placed.remove(&key);
}
}
}
pub(super) fn paths(&self) -> Vec<PathBuf> {
let mine = self.placed.iter();
mine.filter(|(_, placed)| placed.by.contains(&self.manifest))
.map(|(key, _)| self.root.join(key))
.collect()
}
}
fn fingerprint(path: &Path) -> Option<String> {
let metadata = path.symlink_metadata().ok()?;
if metadata.is_symlink() {
let target = std::fs::read_link(path).ok()?;
return Some(format!("link:{}", target.to_string_lossy()));
}
tree_hash(path).ok()
}
pub(super) fn content_hash(path: &Path) -> Option<String> {
let metadata = path.symlink_metadata().ok()?;
let held = match metadata.is_symlink() {
true => path.canonicalize().ok()?,
false => path.to_path_buf(),
};
tree_hash(&held).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pending_hash_accepted() {
let dir = std::env::temp_dir().join(format!("yapi-chest-record-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (pdf, new) = (dir.join("pdf"), dir.join("new"));
for (folder, text) in [(&pdf, "old"), (&new, "new")] {
std::fs::create_dir_all(folder).unwrap();
std::fs::write(folder.join("SKILL.md"), text).unwrap();
}
let (old_hash, new_hash) = (tree_hash(&pdf).unwrap(), tree_hash(&new).unwrap());
let mut record = Record::load(&dir.join("record.toml"), &dir, "chest.toml");
record.adopt(&pdf, &old_hash);
record.expect(&pdf, &new_hash);
assert_eq!(record.held(&pdf), Held::Owned);
std::fs::write(pdf.join("SKILL.md"), "new").unwrap();
assert_eq!(record.held(&pdf), Held::Owned);
record.noted(&pdf, &new_hash);
std::fs::write(pdf.join("SKILL.md"), "old").unwrap();
assert_eq!(record.held(&pdf), Held::Modified);
let _ = std::fs::remove_dir_all(&dir);
}
}