mod common;
use anyhow::Result;
use common::TestEnv;
use dotstate::utils::profile_manifest::ProfileInfo;
#[test]
fn create_profile_initializes_directory_and_manifest() -> Result<()> {
let env = TestEnv::new().with_profile("default").with_git().build()?;
let new_profile_dir = env.profile_path("work");
std::fs::create_dir_all(&new_profile_dir)?;
let mut manifest = env.load_manifest()?;
manifest.profiles.push(ProfileInfo {
name: "work".to_string(),
description: Some("Work profile".to_string()),
inherits: None,
synced_files: Vec::new(),
packages: Vec::new(),
});
manifest.save(&env.repo_path)?;
assert!(new_profile_dir.exists());
env.assert_profile_exists("work");
let manifest = env.load_manifest()?;
let work_profile = manifest.profiles.iter().find(|p| p.name == "work").unwrap();
assert_eq!(work_profile.description, Some("Work profile".to_string()));
Ok(())
}
#[test]
fn create_profile_copies_from_existing() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_activated_profile("default")
.with_synced_file("default", ".zshrc", "zsh config")
.with_synced_file("default", ".vimrc", "vim config")
.build()?;
let work_dir = env.profile_path("work");
std::fs::create_dir_all(&work_dir)?;
let default_dir = env.profile_path("default");
for entry in std::fs::read_dir(&default_dir)? {
let entry = entry?;
let dest = work_dir.join(entry.file_name());
if entry.path().is_file() {
std::fs::copy(entry.path(), dest)?;
}
}
let mut manifest = env.load_manifest()?;
let default_files = manifest
.profiles
.iter()
.find(|p| p.name == "default")
.map(|p| p.synced_files.clone())
.unwrap_or_default();
manifest.profiles.push(ProfileInfo {
name: "work".to_string(),
description: None,
inherits: None,
synced_files: default_files,
packages: Vec::new(),
});
manifest.save(&env.repo_path)?;
env.assert_profile_exists("work");
assert!(env.profile_file_path("work", ".zshrc").exists());
assert!(env.profile_file_path("work", ".vimrc").exists());
let manifest = env.load_manifest()?;
let work_profile = manifest.profiles.iter().find(|p| p.name == "work").unwrap();
assert!(work_profile.synced_files.contains(&".zshrc".to_string()));
assert!(work_profile.synced_files.contains(&".vimrc".to_string()));
Ok(())
}
#[test]
fn activate_profile_creates_all_symlinks() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_selected_profile("default") .build()?;
let zshrc = env.profile_file_path("default", ".zshrc");
std::fs::write(&zshrc, "zsh config")?;
let vimrc = env.profile_file_path("default", ".vimrc");
std::fs::write(&vimrc, "vim config")?;
let mut manifest = env.load_manifest()?;
if let Some(profile) = manifest.profiles.iter_mut().find(|p| p.name == "default") {
profile.synced_files.push(".zshrc".to_string());
profile.synced_files.push(".vimrc".to_string());
}
manifest.save(&env.repo_path)?;
env.assert_profile_not_activated();
assert!(!env.home_path(".zshrc").exists());
assert!(!env.home_path(".vimrc").exists());
std::os::unix::fs::symlink(&zshrc, env.home_path(".zshrc"))?;
std::os::unix::fs::symlink(&vimrc, env.home_path(".vimrc"))?;
let mut tracking = env.load_tracking()?;
tracking.active_profile = "default".to_string();
tracking
.symlinks
.push(dotstate::utils::symlink_manager::TrackedSymlink {
target: env.home_path(".zshrc"),
source: zshrc.clone(),
created_at: chrono::Utc::now(),
backup: None,
});
tracking
.symlinks
.push(dotstate::utils::symlink_manager::TrackedSymlink {
target: env.home_path(".vimrc"),
source: vimrc.clone(),
created_at: chrono::Utc::now(),
backup: None,
});
env.save_tracking(&tracking)?;
let mut config = env.load_config()?;
config.profile_activated = true;
let config_content = toml::to_string_pretty(&config)?;
std::fs::write(env.config_path(), config_content)?;
env.assert_is_symlink(".zshrc");
env.assert_is_symlink(".vimrc");
env.assert_file_tracked(".zshrc");
env.assert_file_tracked(".vimrc");
env.assert_profile_activated();
Ok(())
}
#[test]
fn activate_profile_includes_common_files() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_activated_profile("default")
.with_synced_file("default", ".profile-specific", "profile only")
.with_common_file(".gitconfig", "common git config")
.build()?;
env.assert_is_symlink(".profile-specific");
env.assert_is_symlink(".gitconfig");
env.assert_file_in_profile("default", ".profile-specific");
env.assert_file_in_common(".gitconfig");
Ok(())
}
#[test]
fn activate_profile_when_home_file_already_exists() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_selected_profile("default")
.with_home_file(".zshrc", "existing content")
.build()?;
let repo_file = env.profile_file_path("default", ".zshrc");
std::fs::write(&repo_file, "repo content")?;
env.assert_home_regular_file(".zshrc");
let backup_path = env.backup_dir.join("zshrc_backup");
std::fs::copy(env.home_path(".zshrc"), &backup_path)?;
std::fs::remove_file(env.home_path(".zshrc"))?;
std::os::unix::fs::symlink(&repo_file, env.home_path(".zshrc"))?;
env.assert_is_symlink(".zshrc");
assert!(backup_path.exists());
assert_eq!(std::fs::read_to_string(&backup_path)?, "existing content");
Ok(())
}
#[test]
fn switch_profile_replaces_symlinks() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_profile("work")
.with_activated_profile("default")
.with_synced_file("default", ".default-file", "default content")
.with_synced_file("default", ".shared-name", "default version")
.build()?;
let work_file = env.profile_file_path("work", ".work-file");
std::fs::write(&work_file, "work content")?;
let work_shared = env.profile_file_path("work", ".shared-name");
std::fs::write(&work_shared, "work version")?;
let mut manifest = env.load_manifest()?;
if let Some(profile) = manifest.profiles.iter_mut().find(|p| p.name == "work") {
profile.synced_files.push(".work-file".to_string());
profile.synced_files.push(".shared-name".to_string());
}
manifest.save(&env.repo_path)?;
env.assert_is_symlink(".default-file");
env.assert_is_symlink(".shared-name");
env.assert_active_profile("default");
std::fs::remove_file(env.home_path(".default-file"))?;
std::fs::remove_file(env.home_path(".shared-name"))?;
std::os::unix::fs::symlink(&work_file, env.home_path(".work-file"))?;
std::os::unix::fs::symlink(&work_shared, env.home_path(".shared-name"))?;
let mut tracking = env.load_tracking()?;
tracking.active_profile = "work".to_string();
tracking.symlinks.clear();
tracking
.symlinks
.push(dotstate::utils::symlink_manager::TrackedSymlink {
target: env.home_path(".work-file"),
source: work_file.clone(),
created_at: chrono::Utc::now(),
backup: None,
});
tracking
.symlinks
.push(dotstate::utils::symlink_manager::TrackedSymlink {
target: env.home_path(".shared-name"),
source: work_shared.clone(),
created_at: chrono::Utc::now(),
backup: None,
});
env.save_tracking(&tracking)?;
let mut config = env.load_config()?;
config.active_profile = "work".to_string();
let config_content = toml::to_string_pretty(&config)?;
std::fs::write(env.config_path(), config_content)?;
assert!(!env.home_path(".default-file").exists());
env.assert_is_symlink(".work-file");
env.assert_is_symlink(".shared-name");
assert_eq!(
env.home_file_content(".shared-name"),
Some("work version".to_string())
);
env.assert_active_profile("work");
Ok(())
}
#[test]
fn switch_profile_preserves_common_files() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_profile("work")
.with_activated_profile("default")
.with_common_file(".gitconfig", "shared config")
.with_synced_file("default", ".default-only", "default")
.build()?;
let work_file = env.profile_file_path("work", ".work-only");
std::fs::write(&work_file, "work")?;
let mut manifest = env.load_manifest()?;
if let Some(profile) = manifest.profiles.iter_mut().find(|p| p.name == "work") {
profile.synced_files.push(".work-only".to_string());
}
manifest.save(&env.repo_path)?;
env.assert_is_symlink(".gitconfig");
let original_target = std::fs::read_link(env.home_path(".gitconfig"))?;
std::fs::remove_file(env.home_path(".default-only"))?;
std::os::unix::fs::symlink(&work_file, env.home_path(".work-only"))?;
let mut config = env.load_config()?;
config.active_profile = "work".to_string();
let config_content = toml::to_string_pretty(&config)?;
std::fs::write(env.config_path(), config_content)?;
env.assert_is_symlink(".gitconfig");
let new_target = std::fs::read_link(env.home_path(".gitconfig"))?;
assert_eq!(original_target, new_target);
assert!(!env.home_path(".default-only").exists());
env.assert_is_symlink(".work-only");
Ok(())
}
#[test]
fn switch_to_same_profile_is_noop() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_activated_profile("default")
.with_synced_file("default", ".zshrc", "content")
.build()?;
let original_tracking = env.load_tracking()?;
let original_config = env.load_config()?;
let new_tracking = env.load_tracking()?;
let new_config = env.load_config()?;
assert_eq!(original_config.active_profile, new_config.active_profile);
assert_eq!(
original_tracking.symlinks.len(),
new_tracking.symlinks.len()
);
env.assert_is_symlink(".zshrc");
Ok(())
}
#[test]
fn delete_inactive_profile_cleans_up() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_profile("work")
.with_activated_profile("default")
.build()?;
let work_file = env.profile_file_path("work", ".workrc");
std::fs::write(&work_file, "work config")?;
env.assert_profile_exists("work");
std::fs::remove_dir_all(env.profile_path("work"))?;
let mut manifest = env.load_manifest()?;
manifest.profiles.retain(|p| p.name != "work");
manifest.save(&env.repo_path)?;
env.assert_profile_not_exists("work");
assert!(!env.profile_path("work").exists());
env.assert_profile_exists("default");
env.assert_active_profile("default");
Ok(())
}
#[test]
fn delete_active_profile_deactivates_first() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_profile("work")
.with_activated_profile("work")
.with_synced_file("work", ".workrc", "work config")
.build()?;
env.assert_is_symlink(".workrc");
env.assert_active_profile("work");
std::fs::remove_file(env.home_path(".workrc"))?;
let mut tracking = env.load_tracking()?;
tracking.symlinks.clear();
tracking.active_profile.clear();
env.save_tracking(&tracking)?;
let mut config = env.load_config()?;
config.active_profile = "default".to_string();
config.profile_activated = false;
let config_content = toml::to_string_pretty(&config)?;
std::fs::write(env.config_path(), config_content)?;
std::fs::remove_dir_all(env.profile_path("work"))?;
let mut manifest = env.load_manifest()?;
manifest.profiles.retain(|p| p.name != "work");
manifest.save(&env.repo_path)?;
env.assert_profile_not_exists("work");
assert!(!env.home_path(".workrc").exists());
env.assert_profile_not_activated();
Ok(())
}
#[test]
fn create_profile_with_duplicate_name_fails() -> Result<()> {
let env = TestEnv::new().with_profile("default").build()?;
let manifest = env.load_manifest()?;
let profile_exists = manifest.profiles.iter().any(|p| p.name == "default");
assert!(profile_exists, "Profile should already exist");
Ok(())
}
#[test]
fn activate_when_repo_file_missing() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_selected_profile("default")
.build()?;
let mut manifest = env.load_manifest()?;
if let Some(profile) = manifest.profiles.iter_mut().find(|p| p.name == "default") {
profile.synced_files.push(".missing-file".to_string());
}
manifest.save(&env.repo_path)?;
assert!(!env.profile_file_path("default", ".missing-file").exists());
Ok(())
}
#[test]
fn switch_interrupted_old_symlinks_removed_new_not_created() -> Result<()> {
let env = TestEnv::new()
.with_profile("default")
.with_profile("work")
.with_activated_profile("default")
.with_synced_file("default", ".zshrc", "default zsh")
.build()?;
std::fs::remove_file(env.home_path(".zshrc"))?;
env.assert_active_profile("default");
env.assert_file_tracked(".zshrc");
assert!(!env.home_path(".zshrc").exists());
Ok(())
}
#[test]
fn test_multiple_profiles_isolation() -> Result<()> {
let env = TestEnv::new()
.with_profile("work")
.with_profile("home")
.with_profile("gaming")
.with_activated_profile("work")
.with_synced_file("work", ".workrc", "work config")
.with_synced_file("home", ".homerc", "home config")
.with_synced_file("gaming", ".gamerc", "gaming config")
.build()?;
env.assert_is_symlink(".workrc");
assert!(!env.home_path(".homerc").exists());
assert!(!env.home_path(".gamerc").exists());
assert!(env.profile_file_path("work", ".workrc").exists());
assert!(env.profile_file_path("home", ".homerc").exists());
assert!(env.profile_file_path("gaming", ".gamerc").exists());
env.assert_profile_exists("work");
env.assert_profile_exists("home");
env.assert_profile_exists("gaming");
Ok(())
}
#[test]
fn inheritance_resolve_files_merges_parent_and_child() -> Result<()> {
let env = TestEnv::new()
.with_profile("p1")
.with_profile("p2")
.with_git()
.build()?;
let p1_path = env.profile_path("p1");
std::fs::write(p1_path.join(".zshrc"), "p1 zshrc")?;
std::fs::write(p1_path.join(".vimrc"), "p1 vimrc")?;
let p2_path = env.profile_path("p2");
std::fs::write(p2_path.join(".vimrc"), "p2 vimrc")?;
std::fs::write(p2_path.join(".config_nvim"), "p2 nvim")?;
let common_path = env.common_path();
std::fs::create_dir_all(&common_path)?;
std::fs::write(common_path.join(".gitconfig"), "common gitconfig")?;
let mut manifest = env.load_manifest()?;
manifest.common.synced_files = vec![".gitconfig".to_string()];
if let Some(p1) = manifest.profiles.iter_mut().find(|p| p.name == "p1") {
p1.synced_files = vec![".zshrc".to_string(), ".vimrc".to_string()];
}
if let Some(p2) = manifest.profiles.iter_mut().find(|p| p.name == "p2") {
p2.inherits = Some("p1".to_string());
p2.synced_files = vec![".vimrc".to_string(), ".config_nvim".to_string()];
}
manifest.save(&env.repo_path)?;
let resolved = manifest.resolve_files("p2")?;
assert_eq!(resolved.len(), 4);
let find = |path: &str| resolved.iter().find(|r| r.relative_path == path).unwrap();
assert_eq!(find(".gitconfig").source_profile, "common");
assert_eq!(find(".zshrc").source_profile, "p1"); assert_eq!(find(".vimrc").source_profile, "p2"); assert_eq!(find(".config_nvim").source_profile, "p2");
Ok(())
}
#[test]
fn inheritance_chain_validation_detects_cycle() -> Result<()> {
let env = TestEnv::new()
.with_profile("a")
.with_profile("b")
.with_git()
.build()?;
let mut manifest = env.load_manifest()?;
if let Some(a) = manifest.profiles.iter_mut().find(|p| p.name == "a") {
a.inherits = Some("b".to_string());
}
if let Some(b) = manifest.profiles.iter_mut().find(|p| p.name == "b") {
b.inherits = Some("a".to_string());
}
let result = manifest.validate_inheritance();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cycle"));
Ok(())
}
#[test]
fn inheritance_delete_guard_prevents_deleting_parent() -> Result<()> {
use dotstate::services::ProfileService;
let env = TestEnv::new()
.with_profile("base")
.with_profile("child")
.with_activated_profile("child")
.with_git()
.build()?;
let mut manifest = env.load_manifest()?;
if let Some(child) = manifest.profiles.iter_mut().find(|p| p.name == "child") {
child.inherits = Some("base".to_string());
}
manifest.save(&env.repo_path)?;
let result = ProfileService::delete_profile(&env.repo_path, "base", "child");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("inherited by"));
Ok(())
}
#[test]
fn inheritance_inherits_field_roundtrips_through_toml() -> Result<()> {
let env = TestEnv::new()
.with_profile("parent")
.with_profile("child")
.with_git()
.build()?;
let mut manifest = env.load_manifest()?;
if let Some(child) = manifest.profiles.iter_mut().find(|p| p.name == "child") {
child.inherits = Some("parent".to_string());
}
manifest.save(&env.repo_path)?;
let loaded = env.load_manifest()?;
let child = loaded.profiles.iter().find(|p| p.name == "child").unwrap();
assert_eq!(child.inherits, Some("parent".to_string()));
let parent = loaded.profiles.iter().find(|p| p.name == "parent").unwrap();
assert!(parent.inherits.is_none());
Ok(())
}
#[test]
fn inheritance_profile_without_inherits_is_backward_compatible() -> Result<()> {
let env = TestEnv::new()
.with_profile("standalone")
.with_git()
.build()?;
let profile_path = env.profile_path("standalone");
std::fs::write(profile_path.join(".zshrc"), "standalone zshrc")?;
let common_path = env.common_path();
std::fs::create_dir_all(&common_path)?;
std::fs::write(common_path.join(".gitconfig"), "common gitconfig")?;
let mut manifest = env.load_manifest()?;
manifest.common.synced_files = vec![".gitconfig".to_string()];
if let Some(p) = manifest
.profiles
.iter_mut()
.find(|p| p.name == "standalone")
{
p.synced_files = vec![".zshrc".to_string()];
}
manifest.save(&env.repo_path)?;
let resolved = manifest.resolve_files("standalone")?;
assert_eq!(resolved.len(), 2);
let find = |path: &str| resolved.iter().find(|r| r.relative_path == path).unwrap();
assert_eq!(find(".gitconfig").source_profile, "common");
assert_eq!(find(".zshrc").source_profile, "standalone");
Ok(())
}