use anyhow::{Context, Result};
use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tempfile::TempDir;
use dotstate::config::{Config, RepoMode};
use dotstate::utils::profile_manifest::{ProfileInfo, ProfileManifest};
use dotstate::utils::symlink_manager::{SymlinkTracking, TrackedSymlink};
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard {
old_home: Option<String>,
old_config: Option<String>,
old_backup: Option<String>,
#[allow(dead_code)]
lock: std::sync::MutexGuard<'static, ()>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
Some(v) => std::env::set_var("DOTSTATE_TEST_HOME", v),
None => std::env::remove_var("DOTSTATE_TEST_HOME"),
}
match &self.old_config {
Some(v) => std::env::set_var("DOTSTATE_TEST_CONFIG_DIR", v),
None => std::env::remove_var("DOTSTATE_TEST_CONFIG_DIR"),
}
match &self.old_backup {
Some(v) => std::env::set_var("DOTSTATE_TEST_BACKUP_DIR", v),
None => std::env::remove_var("DOTSTATE_TEST_BACKUP_DIR"),
}
}
}
#[allow(dead_code)]
pub struct TestEnv {
temp_dir: TempDir,
pub home_dir: PathBuf,
pub repo_path: PathBuf,
pub config_dir: PathBuf,
pub backup_dir: PathBuf,
env_guard: Option<EnvGuard>,
}
#[allow(dead_code)]
impl TestEnv {
pub fn new() -> TestEnvBuilder {
TestEnvBuilder::default()
}
pub fn home_path(&self, relative: &str) -> PathBuf {
self.home_dir.join(relative)
}
pub fn repo_file_path(&self, relative: &str) -> PathBuf {
self.repo_path.join(relative)
}
pub fn profile_path(&self, profile_name: &str) -> PathBuf {
self.repo_path.join(profile_name)
}
pub fn profile_file_path(&self, profile_name: &str, file_relative: &str) -> PathBuf {
self.profile_path(profile_name).join(file_relative)
}
pub fn common_path(&self) -> PathBuf {
self.repo_path.join("common")
}
pub fn config_path(&self) -> PathBuf {
self.config_dir.join("config.toml")
}
pub fn tracking_path(&self) -> PathBuf {
self.config_dir.join("symlinks.json")
}
pub fn load_config(&self) -> Result<Config> {
let content = fs::read_to_string(self.config_path()).context("Failed to read config")?;
toml::from_str(&content).context("Failed to parse config")
}
pub fn save_config(&self, config: &Config) -> Result<()> {
let content = toml::to_string_pretty(config)?;
fs::write(self.config_path(), content)?;
Ok(())
}
pub fn load_manifest(&self) -> Result<ProfileManifest> {
ProfileManifest::load(&self.repo_path)
}
pub fn load_tracking(&self) -> Result<SymlinkTracking> {
let tracking_path = self.tracking_path();
if tracking_path.exists() {
let content = fs::read_to_string(&tracking_path)?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(SymlinkTracking::default())
}
}
pub fn save_tracking(&self, tracking: &SymlinkTracking) -> Result<()> {
let content = serde_json::to_string_pretty(tracking)?;
fs::write(self.tracking_path(), content)?;
Ok(())
}
pub fn symlink_target(&self, home_relative: &str) -> Option<PathBuf> {
let path = self.home_path(home_relative);
fs::read_link(&path).ok()
}
pub fn file_content(&self, path: &Path) -> Option<String> {
fs::read_to_string(path).ok()
}
pub fn home_file_content(&self, relative: &str) -> Option<String> {
fs::read_to_string(self.home_path(relative)).ok()
}
pub fn home_file_exists(&self, relative: &str) -> bool {
self.home_path(relative).exists()
}
pub fn is_symlink(&self, path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
pub fn assert_symlink_points_to(&self, home_relative: &str, expected_target: &Path) {
let home_path = self.home_path(home_relative);
assert!(
self.is_symlink(&home_path),
"Expected {} to be a symlink, but it isn't",
home_path.display()
);
let actual_target = fs::read_link(&home_path)
.unwrap_or_else(|e| panic!("Failed to read symlink {}: {}", home_path.display(), e));
assert_eq!(
actual_target,
expected_target,
"Symlink {} points to {:?}, expected {:?}",
home_path.display(),
actual_target,
expected_target
);
}
pub fn assert_is_symlink(&self, home_relative: &str) {
let home_path = self.home_path(home_relative);
assert!(
self.is_symlink(&home_path),
"Expected {} to be a symlink, but it isn't (exists: {})",
home_path.display(),
home_path.exists()
);
}
pub fn assert_no_symlink(&self, home_relative: &str) {
let home_path = self.home_path(home_relative);
assert!(
!self.is_symlink(&home_path),
"Expected {} to NOT be a symlink, but it is",
home_path.display()
);
}
pub fn assert_regular_file(&self, path: &Path) {
assert!(path.exists(), "Expected {} to exist", path.display());
assert!(
!self.is_symlink(path),
"Expected {} to be a regular file, not a symlink",
path.display()
);
}
pub fn assert_home_regular_file(&self, relative: &str) {
self.assert_regular_file(&self.home_path(relative));
}
pub fn assert_file_tracked(&self, home_relative: &str) {
let tracking = self.load_tracking().expect("Failed to load tracking");
let home_path = self.home_path(home_relative);
assert!(
tracking.symlinks.iter().any(|s| s.target == home_path),
"Expected {} to be tracked, but it isn't. Tracked targets: {:?}",
home_relative,
tracking
.symlinks
.iter()
.map(|s| &s.target)
.collect::<Vec<_>>()
);
}
pub fn assert_file_not_tracked(&self, home_relative: &str) {
let tracking = self.load_tracking().expect("Failed to load tracking");
let home_path = self.home_path(home_relative);
assert!(
!tracking.symlinks.iter().any(|s| s.target == home_path),
"Expected {} to NOT be tracked, but it is",
home_relative
);
}
pub fn assert_file_in_profile(&self, profile_name: &str, file_relative: &str) {
let manifest = self.load_manifest().expect("Failed to load manifest");
let profile = manifest
.profiles
.iter()
.find(|p| p.name == profile_name)
.unwrap_or_else(|| panic!("Profile {} not found in manifest", profile_name));
assert!(
profile.synced_files.contains(&file_relative.to_string()),
"Expected {} to be in profile {}'s synced_files, but it isn't. Files: {:?}",
file_relative,
profile_name,
profile.synced_files
);
}
pub fn assert_file_not_in_profile(&self, profile_name: &str, file_relative: &str) {
let manifest = self.load_manifest().expect("Failed to load manifest");
let profile = manifest
.profiles
.iter()
.find(|p| p.name == profile_name)
.unwrap_or_else(|| panic!("Profile {} not found in manifest", profile_name));
assert!(
!profile.synced_files.contains(&file_relative.to_string()),
"Expected {} to NOT be in profile {}'s synced_files, but it is",
file_relative,
profile_name
);
}
pub fn assert_file_in_common(&self, file_relative: &str) {
let manifest = self.load_manifest().expect("Failed to load manifest");
assert!(
manifest
.common
.synced_files
.contains(&file_relative.to_string()),
"Expected {} to be in common synced_files, but it isn't. Files: {:?}",
file_relative,
manifest.common.synced_files
);
}
pub fn assert_file_not_in_common(&self, file_relative: &str) {
let manifest = self.load_manifest().expect("Failed to load manifest");
assert!(
!manifest
.common
.synced_files
.contains(&file_relative.to_string()),
"Expected {} to NOT be in common synced_files, but it is",
file_relative
);
}
pub fn assert_backup_exists_for(&self, _original_relative: &str) {
let backup_count = fs::read_dir(&self.backup_dir)
.map(|entries| entries.count())
.unwrap_or(0);
assert!(
backup_count > 0,
"Expected backup to exist in {:?}, but backup dir is empty",
self.backup_dir
);
}
pub fn assert_profile_exists(&self, profile_name: &str) {
let manifest = self.load_manifest().expect("Failed to load manifest");
assert!(
manifest.profiles.iter().any(|p| p.name == profile_name),
"Expected profile {} to exist, but it doesn't. Profiles: {:?}",
profile_name,
manifest
.profiles
.iter()
.map(|p| &p.name)
.collect::<Vec<_>>()
);
}
pub fn assert_profile_not_exists(&self, profile_name: &str) {
let manifest = self.load_manifest().expect("Failed to load manifest");
assert!(
!manifest.profiles.iter().any(|p| p.name == profile_name),
"Expected profile {} to NOT exist, but it does",
profile_name
);
}
pub fn assert_active_profile(&self, expected_profile: &str) {
let config = self.load_config().expect("Failed to load config");
assert_eq!(
config.active_profile, expected_profile,
"Expected active profile to be {}, but it's {}",
expected_profile, config.active_profile
);
}
pub fn assert_profile_activated(&self) {
let config = self.load_config().expect("Failed to load config");
assert!(
config.profile_activated,
"Expected profile to be activated, but it isn't"
);
}
pub fn assert_profile_not_activated(&self) {
let config = self.load_config().expect("Failed to load config");
assert!(
!config.profile_activated,
"Expected profile to NOT be activated, but it is"
);
}
pub fn delete_symlink_without_tracking(&self, home_relative: &str) -> Result<()> {
let path = self.home_path(home_relative);
fs::remove_file(&path).context("Failed to delete symlink")
}
pub fn delete_repo_file_without_manifest(&self, repo_relative: &str) -> Result<()> {
let path = self.repo_file_path(repo_relative);
fs::remove_file(&path).context("Failed to delete repo file")
}
pub fn add_tracking_without_symlink(&self, home_relative: &str, source: &Path) -> Result<()> {
let mut tracking = self.load_tracking()?;
tracking.symlinks.push(TrackedSymlink {
target: self.home_path(home_relative),
source: source.to_path_buf(),
created_at: chrono::Utc::now(),
backup: None,
});
self.save_tracking(&tracking)
}
pub fn create_symlink_without_tracking(
&self,
home_relative: &str,
target: &Path,
) -> Result<()> {
let link_path = self.home_path(home_relative);
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent)?;
}
symlink(target, &link_path).context("Failed to create symlink")
}
pub fn create_home_file(&self, relative: &str, content: &str) -> Result<()> {
let path = self.home_path(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, content)?;
Ok(())
}
pub fn remove_from_manifest(&self, profile_name: &str, file_relative: &str) -> Result<()> {
let mut manifest = self.load_manifest()?;
if let Some(profile) = manifest
.profiles
.iter_mut()
.find(|p| p.name == profile_name)
{
profile.synced_files.retain(|f| f != file_relative);
}
manifest.save(&self.repo_path)
}
}
#[derive(Default)]
#[allow(dead_code)]
pub struct TestEnvBuilder {
profiles: Vec<(String, Option<String>)>, active_profile: Option<String>,
profile_activated: bool,
home_files: Vec<(String, String)>, synced_files: Vec<(String, String, String)>, common_files: Vec<(String, String)>, backup_enabled: bool,
init_git: bool,
env_override: bool,
}
#[allow(dead_code)]
impl TestEnvBuilder {
pub fn with_profile(mut self, name: &str) -> Self {
self.profiles.push((name.to_string(), None));
self
}
pub fn with_profile_desc(mut self, name: &str, description: &str) -> Self {
self.profiles
.push((name.to_string(), Some(description.to_string())));
self
}
pub fn with_activated_profile(mut self, name: &str) -> Self {
self.active_profile = Some(name.to_string());
self.profile_activated = true;
self
}
pub fn with_selected_profile(mut self, name: &str) -> Self {
self.active_profile = Some(name.to_string());
self.profile_activated = false;
self
}
pub fn with_home_file(mut self, relative_path: &str, content: &str) -> Self {
self.home_files
.push((relative_path.to_string(), content.to_string()));
self
}
pub fn with_synced_file(mut self, profile: &str, relative_path: &str, content: &str) -> Self {
self.synced_files.push((
profile.to_string(),
relative_path.to_string(),
content.to_string(),
));
self
}
pub fn with_common_file(mut self, relative_path: &str, content: &str) -> Self {
self.common_files
.push((relative_path.to_string(), content.to_string()));
self
}
pub fn with_backup_enabled(mut self) -> Self {
self.backup_enabled = true;
self
}
pub fn with_git(mut self) -> Self {
self.init_git = true;
self
}
pub fn with_env_override(mut self) -> Self {
self.env_override = true;
self
}
pub fn build(self) -> Result<TestEnv> {
let temp_dir = TempDir::new().context("Failed to create temp dir")?;
let base = temp_dir.path();
let home_dir = base.join("home");
let repo_path = base.join("repo");
let config_dir = base.join("config");
let backup_dir = base.join("backups");
fs::create_dir_all(&home_dir)?;
fs::create_dir_all(&repo_path)?;
fs::create_dir_all(&config_dir)?;
fs::create_dir_all(&backup_dir)?;
fs::create_dir_all(repo_path.join("common"))?;
if self.init_git {
std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.context("Failed to init git")?;
}
for (relative_path, content) in &self.home_files {
let full_path = home_dir.join(relative_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&full_path, content)?;
}
let mut manifest = ProfileManifest::default();
for (name, description) in &self.profiles {
let profile_dir = repo_path.join(name);
fs::create_dir_all(&profile_dir)?;
manifest.profiles.push(ProfileInfo {
name: name.clone(),
description: description.clone(),
inherits: None,
synced_files: Vec::new(),
packages: Vec::new(),
});
}
let mut tracking = SymlinkTracking {
version: 1,
active_profile: self.active_profile.clone().unwrap_or_default(),
symlinks: Vec::new(),
};
for (profile, relative_path, content) in &self.synced_files {
let home_path = home_dir.join(relative_path);
let repo_file_path = repo_path.join(profile).join(relative_path);
if let Some(parent) = repo_file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&repo_file_path, content)?;
if self.profile_activated && self.active_profile.as_deref() == Some(profile) {
if let Some(parent) = home_path.parent() {
fs::create_dir_all(parent)?;
}
symlink(&repo_file_path, &home_path)?;
tracking.symlinks.push(TrackedSymlink {
target: home_path.clone(),
source: repo_file_path.clone(),
created_at: chrono::Utc::now(),
backup: None,
});
}
if let Some(profile_info) = manifest.profiles.iter_mut().find(|p| &p.name == profile) {
profile_info.synced_files.push(relative_path.clone());
}
}
for (relative_path, content) in &self.common_files {
let home_path = home_dir.join(relative_path);
let common_file_path = repo_path.join("common").join(relative_path);
if let Some(parent) = common_file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&common_file_path, content)?;
if self.profile_activated {
if let Some(parent) = home_path.parent() {
fs::create_dir_all(parent)?;
}
symlink(&common_file_path, &home_path)?;
tracking.symlinks.push(TrackedSymlink {
target: home_path.clone(),
source: common_file_path.clone(),
created_at: chrono::Utc::now(),
backup: None,
});
}
manifest.common.synced_files.push(relative_path.clone());
}
manifest.save(&repo_path)?;
let tracking_json = serde_json::to_string_pretty(&tracking)?;
fs::write(config_dir.join("symlinks.json"), tracking_json)?;
let config = Config {
repo_path: repo_path.clone(),
repo_mode: RepoMode::Local,
active_profile: self
.active_profile
.clone()
.unwrap_or_else(|| "default".to_string()),
profile_activated: self.profile_activated,
backup_enabled: self.backup_enabled,
..Default::default()
};
let config_content = toml::to_string_pretty(&config)?;
fs::write(config_dir.join("config.toml"), config_content)?;
let env_guard = if self.env_override {
let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let old_home = std::env::var("DOTSTATE_TEST_HOME").ok();
let old_config = std::env::var("DOTSTATE_TEST_CONFIG_DIR").ok();
let old_backup = std::env::var("DOTSTATE_TEST_BACKUP_DIR").ok();
std::env::set_var("DOTSTATE_TEST_HOME", &home_dir);
std::env::set_var("DOTSTATE_TEST_CONFIG_DIR", &config_dir);
std::env::set_var("DOTSTATE_TEST_BACKUP_DIR", &backup_dir);
Some(EnvGuard {
old_home,
old_config,
old_backup,
lock,
})
} else {
None
};
Ok(TestEnv {
temp_dir,
home_dir,
repo_path,
config_dir,
backup_dir,
env_guard,
})
}
}
#[allow(dead_code)]
pub fn minimal_env() -> Result<TestEnv> {
TestEnv::new().with_profile("default").build()
}
#[allow(dead_code)]
pub fn activated_env() -> Result<TestEnv> {
TestEnv::new()
.with_profile("default")
.with_activated_profile("default")
.build()
}
#[allow(dead_code)]
pub fn env_with_synced_file(filename: &str, content: &str) -> Result<TestEnv> {
TestEnv::new()
.with_profile("default")
.with_activated_profile("default")
.with_synced_file("default", filename, content)
.build()
}