use std::collections::HashMap;
use std::path::{Path, PathBuf};
use cap_std::ambient_authority;
use cap_std::fs::Dir;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::pid::Pid;
#[derive(Debug, Error)]
pub enum StoreError {
#[error("could not determine home directory")]
HomeNotFound,
#[error("I/O error at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("JSON error at {path}: {source}")]
Json {
path: PathBuf,
source: serde_json::Error,
},
#[error("project not found: {0}")]
ProjectNotFound(String),
}
fn io_ctx(base: &Path, rel: &Path, source: std::io::Error) -> StoreError {
StoreError::Io {
path: base.join(rel),
source,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum Strategy {
Symlink,
Merge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalIndex {
pub projects: HashMap<String, ProjectEntry>,
}
impl Default for GlobalIndex {
fn default() -> Self {
Self {
projects: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub name: String,
pub last_known_path: PathBuf,
#[serde(rename = "type")]
pub project_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub pid: Pid,
pub files: Vec<FileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub target_path: PathBuf,
pub strategy: Strategy,
#[serde(skip_serializing_if = "Option::is_none")]
pub theirs_hash: Option<String>,
}
pub fn new_manifest(pid: Pid) -> Manifest {
Manifest {
pid,
files: Vec::new(),
}
}
pub fn upsert_project(index: &mut GlobalIndex, pid: &Pid, name: String, path: PathBuf) {
let entry = ProjectEntry {
name,
last_known_path: path,
project_type: pid.kind().to_owned(),
};
index.projects.insert(pid.as_str().to_owned(), entry);
}
pub fn remove_project(index: &mut GlobalIndex, pid: &Pid) -> Option<ProjectEntry> {
index.projects.remove(pid.as_str())
}
impl Manifest {
pub fn find_file(&self, target_path: &Path) -> Option<&FileEntry> {
self.files.iter().find(|f| f.target_path == target_path)
}
pub fn find_file_mut(&mut self, target_path: &Path) -> Option<&mut FileEntry> {
self.files.iter_mut().find(|f| f.target_path == target_path)
}
pub fn upsert_file(&mut self, entry: FileEntry) {
if let Some(existing) = self.find_file_mut(&entry.target_path) {
*existing = entry;
} else {
self.files.push(entry);
}
}
pub fn remove_file(&mut self, target_path: &Path) -> Option<FileEntry> {
let pos = self.files.iter().position(|f| f.target_path == target_path)?;
Some(self.files.remove(pos))
}
}
pub struct Store {
base_path: PathBuf,
dir: Dir,
}
impl Store {
pub fn open() -> Result<Self, StoreError> {
let home = dirs::home_dir().ok_or(StoreError::HomeNotFound)?;
let base_path = home.join(".git-cloak");
Self::open_at(base_path)
}
pub fn open_at(base_path: PathBuf) -> Result<Self, StoreError> {
std::fs::create_dir_all(&base_path).map_err(|source| StoreError::Io {
path: base_path.clone(),
source,
})?;
let dir =
Dir::open_ambient_dir(&base_path, ambient_authority()).map_err(|source| {
StoreError::Io {
path: base_path.clone(),
source,
}
})?;
Ok(Self { base_path, dir })
}
pub fn base_path(&self) -> &Path {
&self.base_path
}
pub fn dir(&self) -> &Dir {
&self.dir
}
pub fn ensure_dirs(&self) -> Result<(), StoreError> {
let rel = Path::new("library");
self.dir
.create_dir_all(rel)
.map_err(|e| io_ctx(&self.base_path, rel, e))?;
#[cfg(unix)]
{
use cap_std::fs::PermissionsExt;
let perms = cap_std::fs::Permissions::from_mode(0o700);
self.dir
.set_permissions(rel, perms)
.map_err(|e| io_ctx(&self.base_path, rel, e))?;
}
Ok(())
}
pub fn ensure_project_sandbox(&self, pid: &Pid) -> Result<PathBuf, StoreError> {
let rel = Path::new("library").join(pid.as_str());
self.dir
.create_dir_all(&rel)
.map_err(|e| io_ctx(&self.base_path, &rel, e))?;
Ok(self.base_path.join(&rel))
}
pub fn load_index(&self) -> Result<GlobalIndex, StoreError> {
let rel = Path::new("index.json");
match self.dir.read_to_string(rel) {
Ok(data) => serde_json::from_str(&data).map_err(|source| StoreError::Json {
path: self.base_path.join(rel),
source,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GlobalIndex::default()),
Err(e) => Err(io_ctx(&self.base_path, rel, e)),
}
}
pub fn save_index(&self, index: &GlobalIndex) -> Result<(), StoreError> {
self.ensure_dirs()?;
let rel = Path::new("index.json");
let data =
serde_json::to_string_pretty(index).map_err(|source| StoreError::Json {
path: self.base_path.join(rel),
source,
})?;
self.dir
.write(rel, data)
.map_err(|e| io_ctx(&self.base_path, rel, e))
}
fn manifest_rel(&self, pid: &Pid) -> PathBuf {
Path::new("library")
.join(pid.as_str())
.join("cloak.manifest.json")
}
pub fn load_manifest(&self, pid: &Pid) -> Result<Option<Manifest>, StoreError> {
let rel = self.manifest_rel(pid);
match self.dir.read_to_string(&rel) {
Ok(data) => {
let m =
serde_json::from_str(&data).map_err(|source| StoreError::Json {
path: self.base_path.join(&rel),
source,
})?;
Ok(Some(m))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(io_ctx(&self.base_path, &rel, e)),
}
}
pub fn save_manifest(&self, manifest: &Manifest) -> Result<(), StoreError> {
self.ensure_project_sandbox(&manifest.pid)?;
let rel = self.manifest_rel(&manifest.pid);
let data =
serde_json::to_string_pretty(manifest).map_err(|source| StoreError::Json {
path: self.base_path.join(&rel),
source,
})?;
self.dir
.write(&rel, data)
.map_err(|e| io_ctx(&self.base_path, &rel, e))
}
pub fn stored_file_rel(&self, pid: &Pid, target_path: &Path) -> PathBuf {
Path::new("library")
.join(pid.as_str())
.join("files")
.join(target_path)
}
pub fn stored_file_abs(&self, pid: &Pid, target_path: &Path) -> PathBuf {
self.base_path
.join("library")
.join(pid.as_str())
.join("files")
.join(target_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pid::Pid;
use tempfile::TempDir;
fn tmp_store() -> (TempDir, Store) {
let dir = TempDir::new().unwrap();
let store = Store::open_at(dir.path().to_owned()).unwrap();
(dir, store)
}
#[test]
fn ensure_dirs_creates_library() {
let (_dir, store) = tmp_store();
store.ensure_dirs().unwrap();
assert!(store.base_path.join("library").is_dir());
}
#[cfg(unix)]
#[test]
fn ensure_dirs_sets_permissions() {
use std::os::unix::fs::PermissionsExt;
let (_dir, store) = tmp_store();
store.ensure_dirs().unwrap();
let perms = std::fs::metadata(store.base_path.join("library"))
.unwrap()
.permissions();
assert_eq!(perms.mode() & 0o777, 0o700);
}
#[test]
fn load_index_returns_default_when_missing() {
let (_dir, store) = tmp_store();
let idx = store.load_index().unwrap();
assert!(idx.projects.is_empty());
}
#[test]
fn save_and_load_index_roundtrip() {
let (_dir, store) = tmp_store();
let mut idx = GlobalIndex::default();
let pid = Pid::Git("git_abc123def456ab".into());
upsert_project(
&mut idx,
&pid,
"my-project".into(),
PathBuf::from("/home/user/my-project"),
);
store.save_index(&idx).unwrap();
let loaded = store.load_index().unwrap();
assert_eq!(loaded.projects.len(), 1);
let entry = &loaded.projects["git_abc123def456ab"];
assert_eq!(entry.name, "my-project");
assert_eq!(entry.project_type, "git");
}
#[test]
fn remove_project_works() {
let mut idx = GlobalIndex::default();
let pid = Pid::Git("git_abc123".into());
upsert_project(&mut idx, &pid, "p".into(), PathBuf::from("/p"));
assert_eq!(idx.projects.len(), 1);
let removed = remove_project(&mut idx, &pid);
assert!(removed.is_some());
assert!(idx.projects.is_empty());
}
#[test]
fn load_manifest_returns_none_when_missing() {
let (_dir, store) = tmp_store();
let pid = Pid::Git("git_abc123".into());
assert!(store.load_manifest(&pid).unwrap().is_none());
}
#[test]
fn save_and_load_manifest_roundtrip() {
let (_dir, store) = tmp_store();
let pid = Pid::Git("git_abc123def456ab".into());
let mut m = new_manifest(pid.clone());
m.upsert_file(FileEntry {
target_path: PathBuf::from(".env.local"),
strategy: Strategy::Symlink,
theirs_hash: None,
});
store.save_manifest(&m).unwrap();
let loaded = store.load_manifest(&pid).unwrap().unwrap();
assert_eq!(loaded.files.len(), 1);
assert_eq!(loaded.files[0].target_path, Path::new(".env.local"));
assert_eq!(loaded.files[0].strategy, Strategy::Symlink);
}
#[test]
fn manifest_upsert_and_find() {
let pid = Pid::Git("git_test".into());
let mut m = new_manifest(pid);
m.upsert_file(FileEntry {
target_path: PathBuf::from("a.txt"),
strategy: Strategy::Symlink,
theirs_hash: None,
});
assert!(m.find_file(Path::new("a.txt")).is_some());
assert!(m.find_file(Path::new("b.txt")).is_none());
m.upsert_file(FileEntry {
target_path: PathBuf::from("a.txt"),
strategy: Strategy::Merge,
theirs_hash: Some("abc".into()),
});
assert_eq!(m.files.len(), 1);
assert_eq!(m.files[0].strategy, Strategy::Merge);
}
#[test]
fn manifest_remove_file() {
let pid = Pid::Git("git_test".into());
let mut m = new_manifest(pid);
m.upsert_file(FileEntry {
target_path: PathBuf::from("a.txt"),
strategy: Strategy::Symlink,
theirs_hash: None,
});
let removed = m.remove_file(Path::new("a.txt"));
assert!(removed.is_some());
assert!(m.files.is_empty());
assert!(m.remove_file(Path::new("a.txt")).is_none());
}
#[test]
fn stored_file_path_layout() {
let pid = Pid::Git("git_abc123".into());
let rel = Path::new("library")
.join(pid.as_str())
.join("files")
.join("configs/app.toml");
assert_eq!(
rel,
PathBuf::from("library/git_abc123/files/configs/app.toml")
);
let base = PathBuf::from("/fake/.git-cloak");
let abs = base.join(&rel);
assert_eq!(
abs,
PathBuf::from("/fake/.git-cloak/library/git_abc123/files/configs/app.toml")
);
}
#[test]
fn json_format_matches_spec() {
let mut idx = GlobalIndex::default();
let pid = Pid::Git("git_abc123def456ab".into());
upsert_project(
&mut idx,
&pid,
"my-project".into(),
PathBuf::from("/home/user/my-project"),
);
let idx_json: serde_json::Value = serde_json::from_str(
&serde_json::to_string_pretty(&idx).unwrap(),
)
.unwrap();
let projects = idx_json["projects"].as_object().unwrap();
let entry = &projects["git_abc123def456ab"];
assert_eq!(entry["name"], "my-project");
assert_eq!(entry["type"], "git");
assert_eq!(entry["last_known_path"], "/home/user/my-project");
let mut m = new_manifest(pid);
m.upsert_file(FileEntry {
target_path: PathBuf::from(".env.local"),
strategy: Strategy::Symlink,
theirs_hash: None,
});
let m_json: serde_json::Value = serde_json::from_str(
&serde_json::to_string_pretty(&m).unwrap(),
)
.unwrap();
assert_eq!(m_json["pid"], "git_abc123def456ab");
let file0 = &m_json["files"][0];
assert_eq!(file0["target_path"], ".env.local");
assert_eq!(file0["strategy"], "symlink");
assert!(file0.get("theirs_hash").is_none()); }
}