use std::path::{Component, Path, PathBuf};
use thiserror::Error;
use cap_std::ambient_authority;
use cap_std::fs::Dir;
use crate::git_utils::{self, GitUtilError};
use crate::pid::{self, Pid, PidError};
use crate::store::{self, FileEntry, Store, StoreError, Strategy};
#[derive(Debug, Error)]
pub enum CloakError {
#[error("{0}")]
Pid(#[from] PidError),
#[error("{0}")]
Store(#[from] StoreError),
#[error("{0}")]
GitUtil(#[from] GitUtilError),
#[error("file not found: {0}")]
FileNotFound(PathBuf),
#[error("target occupied by non-cloak file: {0}")]
TargetOccupied(PathBuf),
#[error("file not tracked: {0}")]
NotTracked(PathBuf),
#[error("file {file} is outside repository root {root}")]
OutsideRepo { file: PathBuf, root: PathBuf },
#[error("invalid target path (contains \"..\"): {0}")]
InvalidTargetPath(PathBuf),
#[error("invalid repository root (no .git found): {0}")]
InvalidRepoRoot(PathBuf),
#[error("I/O error at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
}
fn io_err(path: &Path, source: std::io::Error) -> CloakError {
CloakError::Io {
path: path.to_owned(),
source,
}
}
struct Context {
cwd: PathBuf,
cwd_canonical: PathBuf,
repo_root: Option<PathBuf>, repo_dir: Option<Dir>,
pid: Pid,
store: Store,
}
impl Context {
fn resolve() -> Result<Self, CloakError> {
let cwd = std::env::current_dir().map_err(|e| io_err(Path::new("."), e))?;
let cwd_canonical = std::fs::canonicalize(&cwd).map_err(|e| io_err(&cwd, e))?;
let repo_root = git_utils::repo_root(&cwd)
.ok()
.map(|r| std::fs::canonicalize(&r).unwrap_or(r));
let pid_dir = repo_root.as_deref().unwrap_or(&cwd_canonical);
let pid = pid::compute(pid_dir)?;
let store = Store::open()?;
store.ensure_dirs()?;
store.ensure_project_sandbox(&pid)?;
let dir_path = repo_root.as_deref().unwrap_or(&cwd_canonical);
let repo_dir = Dir::open_ambient_dir(dir_path, ambient_authority())
.map_err(|e| io_err(dir_path, e))?;
if repo_root.is_some() && !repo_dir.symlink_metadata(".git").is_ok() {
return Err(CloakError::InvalidRepoRoot(dir_path.to_owned())); }
Ok(Context {
cwd,
cwd_canonical,
repo_root,
repo_dir: Some(repo_dir),
pid,
store,
})
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut out = Vec::new();
for c in path.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
out.iter().collect()
}
fn resolve_target_path(
file: &Path,
explicit_target: Option<&Path>,
repo_root_canonical: Option<&Path>,
) -> Result<PathBuf, CloakError> {
if let Some(target) = explicit_target {
validate_target_path(target)?;
return Ok(target.to_owned());
}
let normalized = normalize_path(file);
let root = repo_root_canonical.unwrap_or_else(|| Path::new("."));
normalized
.strip_prefix(root)
.map(|p| p.to_owned())
.map_err(|_| CloakError::OutsideRepo {
file: normalized,
root: root.to_owned(),
})
}
fn validate_target_path(target: &Path) -> Result<(), CloakError> {
for component in target.components() {
if let std::path::Component::ParentDir = component {
return Err(CloakError::InvalidTargetPath(target.to_owned()));
}
}
if target.is_absolute() {
return Err(CloakError::InvalidTargetPath(target.to_owned()));
}
Ok(())
}
fn is_cloak_symlink(dir: &Dir, rel_path: &Path, library_abs: &Path) -> bool {
dir.read_link_contents(rel_path)
.map(|target| target.starts_with(library_abs))
.unwrap_or(false)
}
enum ExcludeHandle {
InRepo(PathBuf),
External(Dir, PathBuf),
}
fn resolve_exclude(repo_root: &Path) -> Option<(ExcludeHandle, PathBuf)> {
let exclude_abs = git_utils::exclude_path(repo_root).ok()?;
let rel_to_repo = exclude_abs.strip_prefix(repo_root).ok();
if let Some(rel) = rel_to_repo {
Some((ExcludeHandle::InRepo(rel.to_owned()), exclude_abs))
} else {
let parent = exclude_abs.parent()?;
let file_name = exclude_abs.file_name()?;
let dir = Dir::open_ambient_dir(parent, ambient_authority()).ok()?;
Some((
ExcludeHandle::External(dir, PathBuf::from(file_name)),
exclude_abs,
))
}
}
fn with_exclude_dir<F>(handle: &ExcludeHandle, repo_dir: &Dir, abs_context: &Path, f: F)
where
F: FnOnce(&Dir, &Path, &Path),
{
match handle {
ExcludeHandle::InRepo(rel) => f(repo_dir, rel, abs_context),
ExcludeHandle::External(dir, rel) => f(dir, rel, abs_context),
}
}
fn safe_remove_in_dir(
store: &Store,
source_dir: &Dir,
rel_path: &Path,
abs_context: &Path, ) -> Result<(), CloakError> {
let trash_rel = Path::new("trash");
store
.dir()
.create_dir_all(trash_rel)
.map_err(|e| io_err(&store.base_path().join("trash"), e))?;
let name = rel_path.file_name().unwrap_or_default().to_string_lossy();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let trash_dest = trash_rel.join(format!("{name}.{ts}"));
if source_dir
.rename(rel_path, store.dir(), &trash_dest)
.is_ok()
{
return Ok(());
}
Err(CloakError::Io {
path: abs_context.join(rel_path),
source: std::io::Error::new(std::io::ErrorKind::Other, "Failed to rename file"),
})
}
fn inject_single_file(
store: &Store,
pid: &Pid,
target_path: &Path,
repo_dir: &Dir,
repo_root: &Path, ) -> Result<(), CloakError> {
let library_abs = store.base_path().join("library");
if is_cloak_symlink(repo_dir, target_path, &library_abs) {
return Ok(());
}
if repo_dir.symlink_metadata(target_path).is_ok() {
return Err(CloakError::TargetOccupied(repo_root.join(target_path)));
}
if let Some(parent) = target_path.parent() {
if !parent.as_os_str().is_empty() {
repo_dir
.create_dir_all(parent)
.map_err(|e| io_err(&repo_root.join(parent), e))?;
}
}
let stored_abs = store.stored_file_abs(pid, target_path);
#[cfg(unix)]
repo_dir
.symlink_contents(&stored_abs, target_path)
.map_err(|e| io_err(&repo_root.join(target_path), e))?;
#[cfg(not(unix))]
std::os::windows::fs::symlink_file(&stored_abs, &repo_root.join(target_path))
.map_err(|e| io_err(&repo_root.join(target_path), e))?;
if let Some((handle, abs)) = resolve_exclude(repo_root) {
let entry = target_path.to_string_lossy();
with_exclude_dir(&handle, repo_dir, &abs, |dir, rel, abs_ctx| {
let _ = git_utils::ensure_excluded(dir, rel, abs_ctx, &[&entry]);
});
}
Ok(())
}
fn eject_single_file(
store: &Store,
target_path: &Path,
repo_dir: &Dir,
repo_root: &Path,
library_abs: &Path,
) -> Result<(), CloakError> {
if is_cloak_symlink(repo_dir, target_path, library_abs) {
safe_remove_in_dir(store, repo_dir, target_path, repo_root)?;
}
if let Some((handle, abs)) = resolve_exclude(repo_root) {
let entry = target_path.to_string_lossy();
with_exclude_dir(&handle, repo_dir, &abs, |dir, rel, abs_ctx| {
let _ = git_utils::remove_excluded(dir, rel, abs_ctx, &[&entry]);
});
}
Ok(())
}
fn rename_or_copy(
src_dir: &Dir,
src_rel: &Path,
dst_dir: &Dir,
dst_rel: &Path,
abs_src_base: &Path, abs_dst_base: &Path, ) -> Result<(), CloakError> {
if let Some(parent) = dst_rel.parent() {
if !parent.as_os_str().is_empty() {
dst_dir
.create_dir_all(parent)
.map_err(|e| io_err(&abs_dst_base.join(parent), e))?;
}
}
match src_dir.rename(src_rel, dst_dir, dst_rel) {
Ok(()) => Ok(()),
Err(e) => {
#[cfg(unix)]
let is_xdev = e.raw_os_error() == Some(libc::EXDEV);
#[cfg(not(unix))]
let is_xdev = true;
if is_xdev {
src_dir
.copy(src_rel, dst_dir, dst_rel)
.map_err(|e| io_err(&abs_dst_base.join(dst_rel), e))?;
src_dir
.remove_file(src_rel)
.map_err(|e| io_err(&abs_src_base.join(src_rel), e))?;
Ok(())
} else {
Err(io_err(&abs_dst_base.join(dst_rel), e))
}
}
}
}
pub fn track(file: &Path, target_path: Option<&Path>) -> Result<(), CloakError> {
let ctx = Context::resolve()?;
let cwd = &ctx.cwd;
let file_abs = if file.is_absolute() {
file.to_owned()
} else {
cwd.join(file)
};
let root = ctx.repo_root.as_deref().unwrap_or(&ctx.cwd_canonical);
let repo_dir = ctx.repo_dir.as_ref().unwrap();
let library_abs = ctx.store.base_path().join("library");
let file_normalized = normalize_path(&file_abs);
let file_repo_rel = file_normalized
.strip_prefix(root)
.map(|p| p.to_owned())
.map_err(|_| CloakError::OutsideRepo {
file: file_normalized.clone(),
root: root.to_owned(),
})?;
if repo_dir.symlink_metadata(&file_repo_rel).is_err() {
return Err(CloakError::FileNotFound(file_abs));
}
if is_cloak_symlink(repo_dir, &file_repo_rel, &library_abs) {
println!("Already tracked: {}", file_repo_rel.display());
return Ok(());
}
let target = resolve_target_path(&file_abs, target_path, Some(root))?;
let stored_rel = ctx.store.stored_file_rel(&ctx.pid, &target);
if ctx.store.dir().exists(&stored_rel) && is_cloak_symlink(repo_dir, &target, &library_abs) {
println!("Already tracked: {}", target.display());
return Ok(());
}
rename_or_copy(
repo_dir,
&file_repo_rel,
ctx.store.dir(),
&stored_rel,
root,
ctx.store.base_path(),
)?;
let mut manifest = ctx
.store
.load_manifest(&ctx.pid)?
.unwrap_or_else(|| store::new_manifest(ctx.pid.clone()));
manifest.upsert_file(FileEntry {
target_path: target.clone(),
strategy: Strategy::Symlink,
theirs_hash: None,
});
ctx.store.save_manifest(&manifest)?;
let mut index = ctx.store.load_index()?;
let project_name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| ctx.pid.to_string());
store::upsert_project(&mut index, &ctx.pid, project_name, root.to_owned());
ctx.store.save_index(&index)?;
inject_single_file(&ctx.store, &ctx.pid, &target, repo_dir, root)?;
println!("Tracked: {}", target.display());
Ok(())
}
pub fn untrack(file_in_project: &Path) -> Result<(), CloakError> {
let ctx = Context::resolve()?;
let root = ctx.repo_root.as_deref().unwrap_or(&ctx.cwd_canonical);
let repo_dir = ctx.repo_dir.as_ref().unwrap();
let target = if file_in_project.is_absolute() {
file_in_project
.strip_prefix(root)
.map(|p| p.to_owned())
.map_err(|_| CloakError::OutsideRepo {
file: file_in_project.to_owned(),
root: root.to_owned(),
})?
} else {
let abs = normalize_path(&ctx.cwd_canonical.join(file_in_project));
abs.strip_prefix(root)
.map(|p| p.to_owned())
.unwrap_or_else(|_| file_in_project.to_owned())
};
let mut manifest = ctx
.store
.load_manifest(&ctx.pid)?
.ok_or_else(|| CloakError::NotTracked(target.clone()))?;
if manifest.find_file(&target).is_none() {
return Err(CloakError::NotTracked(target));
}
let library_abs = ctx.store.base_path().join("library");
eject_single_file(&ctx.store, &target, repo_dir, root, &library_abs)?;
let stored_rel = ctx.store.stored_file_rel(&ctx.pid, &target);
if ctx.store.dir().exists(&stored_rel) {
rename_or_copy(
ctx.store.dir(),
&stored_rel,
repo_dir,
&target,
ctx.store.base_path(),
root,
)?;
}
manifest.remove_file(&target);
ctx.store.save_manifest(&manifest)?;
if manifest.files.is_empty() {
let mut index = ctx.store.load_index()?;
store::remove_project(&mut index, &ctx.pid);
ctx.store.save_index(&index)?;
}
println!("Untracked: {}", target.display());
Ok(())
}
pub fn inject() -> Result<(), CloakError> {
let ctx = Context::resolve()?;
let root = ctx.repo_root.as_deref().unwrap_or(&ctx.cwd_canonical);
let repo_dir = ctx.repo_dir.as_ref().unwrap();
let manifest = match ctx.store.load_manifest(&ctx.pid)? {
Some(m) => m,
None => {
println!("No tracked files for this project.");
return Ok(());
}
};
for entry in &manifest.files {
if entry.strategy == Strategy::Symlink {
inject_single_file(&ctx.store, &ctx.pid, &entry.target_path, repo_dir, root)?;
}
}
let mut index = ctx.store.load_index()?;
if let Some(project) = index.projects.get_mut(ctx.pid.as_str()) {
project.last_known_path = root.to_owned();
}
ctx.store.save_index(&index)?;
println!(
"Injected {} file(s).",
manifest
.files
.iter()
.filter(|f| f.strategy == Strategy::Symlink)
.count()
);
Ok(())
}
pub fn eject() -> Result<(), CloakError> {
let ctx = Context::resolve()?;
let root = ctx.repo_root.as_deref().unwrap_or(&ctx.cwd_canonical);
let repo_dir = ctx.repo_dir.as_ref().unwrap();
let manifest = match ctx.store.load_manifest(&ctx.pid)? {
Some(m) => m,
None => {
println!("No tracked files for this project.");
return Ok(());
}
};
let library_abs = ctx.store.base_path().join("library");
let target_strs: Vec<String> = manifest
.files
.iter()
.map(|f| f.target_path.to_string_lossy().into_owned())
.collect();
for entry in &manifest.files {
if is_cloak_symlink(repo_dir, &entry.target_path, &library_abs) {
safe_remove_in_dir(&ctx.store, repo_dir, &entry.target_path, root)?;
}
}
if let Some((handle, abs)) = resolve_exclude(root) {
let refs: Vec<&str> = target_strs.iter().map(|s| s.as_str()).collect();
with_exclude_dir(&handle, repo_dir, &abs, |dir, rel, abs_ctx| {
let _ = git_utils::remove_excluded(dir, rel, abs_ctx, &refs);
});
}
println!("Ejected {} file(s).", manifest.files.len());
Ok(())
}
pub fn status() -> Result<(), CloakError> {
let ctx = Context::resolve()?;
let repo_dir = ctx.repo_dir.as_ref().unwrap();
let library_abs = ctx.store.base_path().join("library");
let manifest = match ctx.store.load_manifest(&ctx.pid)? {
Some(m) => m,
None => {
println!("No tracked files.");
return Ok(());
}
};
if manifest.files.is_empty() {
println!("No tracked files.");
return Ok(());
}
for entry in &manifest.files {
let stored_rel = ctx.store.stored_file_rel(&ctx.pid, &entry.target_path);
let stored_exists = ctx.store.dir().exists(&stored_rel);
let state = if !stored_exists {
"Orphaned"
} else if is_cloak_symlink(repo_dir, &entry.target_path, &library_abs) {
"Linked"
} else {
"Ejected"
};
let strategy = match entry.strategy {
Strategy::Symlink => "symlink",
Strategy::Merge => "merge",
};
println!(
"{} {} {}",
entry.target_path.display(),
strategy,
state,
);
}
Ok(())
}
pub fn projects() -> Result<(), CloakError> {
let store = Store::open()?;
let index = store.load_index()?;
if index.projects.is_empty() {
println!("No managed projects.");
return Ok(());
}
for (_pid, entry) in &index.projects {
println!(
"{} {} {}",
entry.name,
entry.project_type,
entry.last_known_path.display(),
);
}
Ok(())
}