use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct ShadowWorkspace {
real_root: PathBuf,
shadow_root: PathBuf,
target_dir: PathBuf,
}
impl ShadowWorkspace {
pub(crate) async fn build(real_root: PathBuf) -> std::io::Result<Self> {
let live_root = real_root.join("target").join("bacon-ls-live");
let shadow_root = live_root.join("shadow");
let target_dir = live_root.join("target");
if tokio::fs::try_exists(&shadow_root).await? {
tokio::fs::remove_dir_all(&shadow_root).await?;
}
tokio::fs::create_dir_all(&shadow_root).await?;
tokio::fs::create_dir_all(&target_dir).await?;
let real = real_root.clone();
let shadow = shadow_root.clone();
let live = live_root.clone();
tokio::task::spawn_blocking(move || mirror_blocking(&real, &shadow, &live))
.await
.map_err(std::io::Error::other)??;
Ok(Self {
real_root,
shadow_root,
target_dir,
})
}
pub(crate) fn real_root(&self) -> &Path {
&self.real_root
}
pub(crate) fn shadow_root(&self) -> &Path {
&self.shadow_root
}
pub(crate) fn target_dir(&self) -> &Path {
&self.target_dir
}
pub(crate) fn shadow_path_for(&self, real_path: &Path) -> std::io::Result<PathBuf> {
let rel = real_path.strip_prefix(&self.real_root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"path {} is not inside workspace {}",
real_path.display(),
self.real_root.display(),
),
)
})?;
Ok(self.shadow_root.join(rel))
}
pub(crate) async fn write_dirty(&self, real_path: &Path, content: &str) -> std::io::Result<()> {
let shadow_path = self.shadow_path_for(real_path)?;
if let Some(parent) = shadow_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = shadow_path.with_extension("bacon-ls-tmp");
tokio::fs::write(&tmp, content).await?;
tokio::fs::rename(&tmp, &shadow_path).await?;
Ok(())
}
pub(crate) async fn restore_link(&self, real_path: &Path) -> std::io::Result<()> {
let shadow_path = self.shadow_path_for(real_path)?;
if tokio::fs::try_exists(&shadow_path).await? {
tokio::fs::remove_file(&shadow_path).await?;
}
if let Some(parent) = shadow_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::hard_link(real_path, &shadow_path).await?;
Ok(())
}
}
fn mirror_blocking(real: &Path, shadow: &Path, live_root: &Path) -> std::io::Result<()> {
use ignore::WalkBuilder;
let walker = WalkBuilder::new(real)
.hidden(true)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.require_git(false)
.parents(true)
.filter_entry({
let live_root = live_root.to_path_buf();
move |e| !e.path().starts_with(&live_root)
})
.build();
for result in walker {
let entry = match result {
Ok(e) => e,
Err(err) => {
tracing::warn!(?err, "skipping entry while mirroring shadow");
continue;
}
};
let path = entry.path();
let rel = match path.strip_prefix(real) {
Ok(r) if !r.as_os_str().is_empty() => r,
_ => continue,
};
let shadow_path = shadow.join(rel);
let ft = match entry.file_type() {
Some(ft) => ft,
None => continue,
};
if ft.is_dir() {
std::fs::create_dir_all(&shadow_path)?;
} else if ft.is_file() {
if let Some(parent) = shadow_path.parent() {
std::fs::create_dir_all(parent)?;
}
if std::fs::metadata(&shadow_path).is_ok() {
std::fs::remove_file(&shadow_path)?;
}
std::fs::hard_link(path, &shadow_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[cfg(unix)]
fn inode(path: &Path) -> u64 {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).unwrap().ino()
}
fn mk_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[tokio::test]
#[cfg(unix)]
async fn test_build_mirrors_files_via_hardlink_same_inode() {
let tmp = TempDir::new().unwrap();
let lib_rs = tmp.path().join("src/lib.rs");
mk_file(&lib_rs, "// content");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
let shadow_lib_rs = shadow.shadow_root().join("src/lib.rs");
assert!(shadow_lib_rs.exists());
assert_eq!(
inode(&lib_rs),
inode(&shadow_lib_rs),
"shadow file must hardlink to the real file (same inode)"
);
}
#[tokio::test]
async fn test_build_excludes_gitignored_paths() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\nbuild-output/\n");
mk_file(&tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"");
mk_file(&tmp.path().join("src/lib.rs"), "// real");
mk_file(&tmp.path().join("target/release/big.rlib"), "binary");
mk_file(&tmp.path().join("build-output/snapshot.bin"), "junk");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
assert!(shadow.shadow_root().join("Cargo.toml").exists());
assert!(shadow.shadow_root().join("src/lib.rs").exists());
assert!(!shadow.shadow_root().join("target").exists());
assert!(!shadow.shadow_root().join("build-output").exists());
}
#[tokio::test]
async fn test_build_excludes_hidden_dirs() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join("Cargo.toml"), "x");
mk_file(&tmp.path().join(".git/HEAD"), "ref:");
mk_file(&tmp.path().join(".idea/workspace.xml"), "<x/>");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
assert!(!shadow.shadow_root().join(".git").exists());
assert!(!shadow.shadow_root().join(".idea").exists());
}
#[tokio::test]
async fn test_build_does_not_recurse_into_its_own_live_dir() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join("Cargo.toml"), "x");
mk_file(&tmp.path().join("src/lib.rs"), "x");
mk_file(
&tmp.path().join("target/bacon-ls-live/shadow/should-not-recurse.rs"),
"x",
);
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
assert!(
!shadow.shadow_root().join("target/bacon-ls-live").exists(),
"shadow must not recurse into its own live dir"
);
}
#[tokio::test]
async fn test_build_wipes_stale_shadow_from_prior_session() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\n");
mk_file(&tmp.path().join("src/lib.rs"), "fresh");
mk_file(
&tmp.path().join("target/bacon-ls-live/shadow/src/lib.rs"),
"stale dirty content",
);
mk_file(
&tmp.path().join("target/bacon-ls-live/shadow/src/gone.rs"),
"deleted in real workspace",
);
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
assert!(
!shadow.shadow_root().join("src/gone.rs").exists(),
"stale shadow entries from prior runs must not survive a rebuild"
);
let mirrored = std::fs::read_to_string(shadow.shadow_root().join("src/lib.rs")).unwrap();
assert_eq!(mirrored, "fresh", "stale dirty content must be replaced");
}
#[tokio::test]
async fn test_build_preserves_target_dir_for_cache_reuse() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\n");
mk_file(&tmp.path().join("src/lib.rs"), "x");
let cache_marker = tmp.path().join("target/bacon-ls-live/target/CACHE_MARKER");
mk_file(&cache_marker, "previous build artifacts");
ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
assert!(
cache_marker.exists(),
"live target dir must persist across rebuilds so cargo can reuse its cache"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_write_dirty_replaces_hardlink_with_distinct_inode() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\n");
let lib_rs = tmp.path().join("src/lib.rs");
mk_file(&lib_rs, "saved");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
let shadow_lib_rs = shadow.shadow_root().join("src/lib.rs");
assert_eq!(inode(&lib_rs), inode(&shadow_lib_rs));
shadow.write_dirty(&lib_rs, "dirty buffer").await.unwrap();
assert_ne!(
inode(&lib_rs),
inode(&shadow_lib_rs),
"write_dirty must break the hardlink"
);
assert_eq!(
std::fs::read_to_string(&shadow_lib_rs).unwrap(),
"dirty buffer",
"shadow now carries dirty content"
);
assert_eq!(
std::fs::read_to_string(&lib_rs).unwrap(),
"saved",
"real file must be untouched"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_restore_link_reverts_to_hardlink() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\n");
let lib_rs = tmp.path().join("src/lib.rs");
mk_file(&lib_rs, "saved");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
let shadow_lib_rs = shadow.shadow_root().join("src/lib.rs");
shadow.write_dirty(&lib_rs, "dirty").await.unwrap();
assert_ne!(inode(&lib_rs), inode(&shadow_lib_rs));
shadow.restore_link(&lib_rs).await.unwrap();
assert_eq!(
inode(&lib_rs),
inode(&shadow_lib_rs),
"restore_link must hardlink shadow back to the real file"
);
assert_eq!(std::fs::read_to_string(&shadow_lib_rs).unwrap(), "saved");
}
#[tokio::test]
async fn test_write_dirty_creates_missing_parent_dirs() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\n");
mk_file(&tmp.path().join("src/lib.rs"), "x");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
let new_file = tmp.path().join("src/new/nested/mod.rs");
std::fs::create_dir_all(new_file.parent().unwrap()).unwrap();
std::fs::write(&new_file, "real").unwrap();
shadow.write_dirty(&new_file, "in-editor draft").await.unwrap();
let shadow_path = shadow.shadow_root().join("src/new/nested/mod.rs");
assert_eq!(std::fs::read_to_string(&shadow_path).unwrap(), "in-editor draft");
}
#[tokio::test]
async fn test_shadow_path_for_outside_workspace_errors() {
let tmp = TempDir::new().unwrap();
mk_file(&tmp.path().join(".gitignore"), "target/\n");
mk_file(&tmp.path().join("src/lib.rs"), "x");
let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap();
let outside = std::path::PathBuf::from("/etc/passwd");
let err = shadow.shadow_path_for(&outside).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
}