mod common;
use anyhow::Result;
use common::TestEnv;
use dotstate::config::RepoMode;
use dotstate::utils::profile_manifest::ProfileInfo;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn local_setup_creates_repo_and_config() -> Result<()> {
let temp_dir = TempDir::new()?;
let base = temp_dir.path();
let repo_path = base.join("dotfiles");
let config_dir = base.join("config");
fs::create_dir_all(&repo_path)?;
fs::create_dir_all(repo_path.join("default"))?;
fs::create_dir_all(repo_path.join("common"))?;
std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()?;
let manifest = dotstate::utils::profile_manifest::ProfileManifest {
profiles: vec![ProfileInfo {
name: "default".to_string(),
description: Some("Default profile".to_string()),
inherits: None,
synced_files: Vec::new(),
packages: Vec::new(),
}],
..Default::default()
};
manifest.save(&repo_path)?;
fs::create_dir_all(&config_dir)?;
let config = dotstate::config::Config {
repo_path: repo_path.clone(),
repo_mode: RepoMode::Local,
active_profile: "default".to_string(),
profile_activated: false,
..Default::default()
};
let config_content = toml::to_string_pretty(&config)?;
fs::write(config_dir.join("config.toml"), config_content)?;
let tracking = dotstate::utils::symlink_manager::SymlinkTracking::default();
let tracking_json = serde_json::to_string_pretty(&tracking)?;
fs::write(config_dir.join("symlinks.json"), tracking_json)?;
assert!(repo_path.exists());
assert!(repo_path.join(".git").exists());
assert!(repo_path.join("default").exists());
assert!(repo_path.join("common").exists());
assert!(repo_path.join(".dotstate-profiles.toml").exists());
assert!(config_dir.join("config.toml").exists());
assert!(config_dir.join("symlinks.json").exists());
let loaded_manifest = dotstate::utils::profile_manifest::ProfileManifest::load(&repo_path)?;
assert_eq!(loaded_manifest.profiles.len(), 1);
assert_eq!(loaded_manifest.profiles[0].name, "default");
let config_content = fs::read_to_string(config_dir.join("config.toml"))?;
let loaded_config: dotstate::config::Config = toml::from_str(&config_content)?;
assert_eq!(loaded_config.repo_mode, RepoMode::Local);
assert_eq!(loaded_config.active_profile, "default");
assert!(!loaded_config.profile_activated);
Ok(())
}
#[test]
fn local_setup_with_custom_path() -> Result<()> {
let temp_dir = TempDir::new()?;
let custom_path = temp_dir.path().join("my-custom-dotfiles");
fs::create_dir_all(&custom_path)?;
fs::create_dir_all(custom_path.join("default"))?;
fs::create_dir_all(custom_path.join("common"))?;
std::process::Command::new("git")
.args(["init"])
.current_dir(&custom_path)
.output()?;
assert!(custom_path.exists());
assert!(custom_path.join(".git").exists());
assert!(custom_path.join("default").exists());
Ok(())
}
#[test]
fn local_setup_initializes_git_repo() -> Result<()> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path().join("repo");
fs::create_dir_all(&repo_path)?;
let output = std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()?;
assert!(output.status.success());
assert!(repo_path.join(".git").exists());
let status_output = std::process::Command::new("git")
.args(["status"])
.current_dir(&repo_path)
.output()?;
assert!(status_output.status.success());
Ok(())
}
#[test]
fn github_setup_clones_existing_repo() -> Result<()> {
let temp_dir = TempDir::new()?;
let base = temp_dir.path();
let remote_path = base.join("remote.git");
fs::create_dir_all(&remote_path)?;
std::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&remote_path)
.output()?;
std::process::Command::new("git")
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.current_dir(&remote_path)
.output()?;
let temp_repo = base.join("temp");
fs::create_dir_all(&temp_repo)?;
std::process::Command::new("git")
.args(["init"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.current_dir(&temp_repo)
.output()?;
fs::create_dir_all(temp_repo.join("default"))?;
fs::create_dir_all(temp_repo.join("common"))?;
let manifest = dotstate::utils::profile_manifest::ProfileManifest {
profiles: vec![ProfileInfo {
name: "default".to_string(),
description: None,
inherits: None,
synced_files: vec![".existing-file".to_string()],
packages: Vec::new(),
}],
..Default::default()
};
manifest.save(&temp_repo)?;
fs::write(temp_repo.join("default/.existing-file"), "existing content")?;
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&temp_repo)
.output()?;
let add_output = std::process::Command::new("git")
.args(["add", "."])
.current_dir(&temp_repo)
.output()?;
assert!(
add_output.status.success(),
"git add failed: {:?}",
String::from_utf8_lossy(&add_output.stderr)
);
let commit_output = std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&temp_repo)
.output()?;
assert!(
commit_output.status.success(),
"git commit failed: {:?}",
String::from_utf8_lossy(&commit_output.stderr)
);
let remote_output = std::process::Command::new("git")
.args(["remote", "add", "origin", remote_path.to_str().unwrap()])
.current_dir(&temp_repo)
.output()?;
assert!(
remote_output.status.success(),
"git remote add failed: {:?}",
String::from_utf8_lossy(&remote_output.stderr)
);
let push_output = std::process::Command::new("git")
.args(["push", "-u", "origin", "HEAD:main"])
.current_dir(&temp_repo)
.output()?;
assert!(
push_output.status.success(),
"git push failed: {:?}",
String::from_utf8_lossy(&push_output.stderr)
);
let clone_path = base.join("cloned");
let clone_output = std::process::Command::new("git")
.args([
"clone",
remote_path.to_str().unwrap(),
clone_path.to_str().unwrap(),
])
.output()?;
assert!(
clone_output.status.success(),
"Clone failed: {:?}",
String::from_utf8_lossy(&clone_output.stderr)
);
assert!(clone_path.exists());
assert!(clone_path.join(".git").exists());
assert!(clone_path.join("default").exists());
assert!(clone_path.join(".dotstate-profiles.toml").exists());
assert!(clone_path.join("default/.existing-file").exists());
let loaded_manifest = dotstate::utils::profile_manifest::ProfileManifest::load(&clone_path)?;
assert_eq!(loaded_manifest.profiles.len(), 1);
assert!(loaded_manifest.profiles[0]
.synced_files
.contains(&".existing-file".to_string()));
Ok(())
}
#[test]
fn github_clone_empty_repo() -> Result<()> {
let temp_dir = TempDir::new()?;
let base = temp_dir.path();
let remote_path = base.join("empty-remote.git");
fs::create_dir_all(&remote_path)?;
std::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&remote_path)
.output()?;
std::process::Command::new("git")
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.current_dir(&remote_path)
.output()?;
let temp_repo = base.join("temp");
fs::create_dir_all(&temp_repo)?;
std::process::Command::new("git")
.args(["init"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&temp_repo)
.output()?;
fs::write(temp_repo.join(".gitkeep"), "")?;
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["remote", "add", "origin", remote_path.to_str().unwrap()])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["push", "-u", "origin", "HEAD:main"])
.current_dir(&temp_repo)
.output()?;
let clone_path = base.join("cloned");
std::process::Command::new("git")
.args([
"clone",
remote_path.to_str().unwrap(),
clone_path.to_str().unwrap(),
])
.output()?;
fs::create_dir_all(clone_path.join("default"))?;
fs::create_dir_all(clone_path.join("common"))?;
let manifest = dotstate::utils::profile_manifest::ProfileManifest {
profiles: vec![ProfileInfo {
name: "default".to_string(),
description: Some("Default profile".to_string()),
inherits: None,
synced_files: Vec::new(),
packages: Vec::new(),
}],
..Default::default()
};
manifest.save(&clone_path)?;
assert!(clone_path.join("default").exists());
assert!(clone_path.join("common").exists());
assert!(clone_path.join(".dotstate-profiles.toml").exists());
Ok(())
}
#[test]
fn setup_when_config_already_exists() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_dir = temp_dir.path().join("config");
fs::create_dir_all(&config_dir)?;
let existing_config = dotstate::config::Config {
repo_path: PathBuf::from("/old/path"),
active_profile: "old-profile".to_string(),
..Default::default()
};
let content = toml::to_string_pretty(&existing_config)?;
fs::write(config_dir.join("config.toml"), content)?;
assert!(config_dir.join("config.toml").exists());
let existing_content = fs::read_to_string(config_dir.join("config.toml"))?;
let loaded: dotstate::config::Config = toml::from_str(&existing_content)?;
assert_eq!(loaded.active_profile, "old-profile");
Ok(())
}
#[test]
fn setup_when_repo_path_exists_not_git() -> Result<()> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path().join("existing-dir");
fs::create_dir_all(&repo_path)?;
fs::write(repo_path.join("some-file.txt"), "content")?;
assert!(repo_path.exists());
assert!(!repo_path.join(".git").exists());
let result = std::process::Command::new("git")
.args(["status"])
.current_dir(&repo_path)
.output()?;
assert!(!result.status.success(), "Should not be a git repo");
Ok(())
}
#[test]
fn setup_when_repo_path_is_already_git() -> Result<()> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path().join("existing-git");
fs::create_dir_all(&repo_path)?;
std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()?;
assert!(repo_path.join(".git").exists());
fs::create_dir_all(repo_path.join("default"))?;
fs::create_dir_all(repo_path.join("common"))?;
let manifest = dotstate::utils::profile_manifest::ProfileManifest {
profiles: vec![ProfileInfo {
name: "default".to_string(),
description: None,
inherits: None,
synced_files: Vec::new(),
packages: Vec::new(),
}],
..Default::default()
};
manifest.save(&repo_path)?;
assert!(repo_path.join(".dotstate-profiles.toml").exists());
Ok(())
}
#[test]
fn github_clone_existing_dotstate_repo() -> Result<()> {
let temp_dir = TempDir::new()?;
let base = temp_dir.path();
let remote_path = base.join("dotstate-remote.git");
fs::create_dir_all(&remote_path)?;
std::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(&remote_path)
.output()?;
std::process::Command::new("git")
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.current_dir(&remote_path)
.output()?;
let temp_repo = base.join("temp");
fs::create_dir_all(&temp_repo)?;
std::process::Command::new("git")
.args(["init"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.current_dir(&temp_repo)
.output()?;
fs::create_dir_all(temp_repo.join("work"))?;
fs::create_dir_all(temp_repo.join("home"))?;
fs::create_dir_all(temp_repo.join("common"))?;
fs::write(temp_repo.join("work/.workrc"), "work config")?;
fs::write(temp_repo.join("home/.homerc"), "home config")?;
fs::write(temp_repo.join("common/.gitconfig"), "shared config")?;
let manifest = dotstate::utils::profile_manifest::ProfileManifest {
version: 1,
common: dotstate::utils::profile_manifest::CommonSection {
synced_files: vec![".gitconfig".to_string()],
},
profiles: vec![
ProfileInfo {
name: "work".to_string(),
description: Some("Work profile".to_string()),
inherits: None,
synced_files: vec![".workrc".to_string()],
packages: Vec::new(),
},
ProfileInfo {
name: "home".to_string(),
description: Some("Home profile".to_string()),
inherits: None,
synced_files: vec![".homerc".to_string()],
packages: Vec::new(),
},
],
};
manifest.save(&temp_repo)?;
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&temp_repo)
.output()?;
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&temp_repo)
.output()?;
let add_output = std::process::Command::new("git")
.args(["add", "."])
.current_dir(&temp_repo)
.output()?;
assert!(
add_output.status.success(),
"git add failed: {:?}",
String::from_utf8_lossy(&add_output.stderr)
);
let commit_output = std::process::Command::new("git")
.args(["commit", "-m", "initial dotstate setup"])
.current_dir(&temp_repo)
.output()?;
assert!(
commit_output.status.success(),
"git commit failed: {:?}",
String::from_utf8_lossy(&commit_output.stderr)
);
let remote_output = std::process::Command::new("git")
.args(["remote", "add", "origin", remote_path.to_str().unwrap()])
.current_dir(&temp_repo)
.output()?;
assert!(
remote_output.status.success(),
"git remote add failed: {:?}",
String::from_utf8_lossy(&remote_output.stderr)
);
let push_output = std::process::Command::new("git")
.args(["push", "-u", "origin", "HEAD:main"])
.current_dir(&temp_repo)
.output()?;
assert!(
push_output.status.success(),
"git push failed: {:?}",
String::from_utf8_lossy(&push_output.stderr)
);
let clone_path = base.join("cloned");
let clone_output = std::process::Command::new("git")
.args([
"clone",
remote_path.to_str().unwrap(),
clone_path.to_str().unwrap(),
])
.output()?;
assert!(
clone_output.status.success(),
"git clone failed: {:?}",
String::from_utf8_lossy(&clone_output.stderr)
);
let loaded_manifest = dotstate::utils::profile_manifest::ProfileManifest::load(&clone_path)?;
assert_eq!(loaded_manifest.profiles.len(), 2);
assert!(loaded_manifest.profiles.iter().any(|p| p.name == "work"));
assert!(loaded_manifest.profiles.iter().any(|p| p.name == "home"));
assert!(loaded_manifest
.common
.synced_files
.contains(&".gitconfig".to_string()));
assert!(clone_path.join("work/.workrc").exists());
assert!(clone_path.join("home/.homerc").exists());
assert!(clone_path.join("common/.gitconfig").exists());
Ok(())
}
#[test]
fn local_setup_fails_on_invalid_path() -> Result<()> {
let invalid_path = PathBuf::from("/nonexistent/deep/nested/path/that/wont/exist");
assert!(!invalid_path.exists());
Ok(())
}
#[test]
fn github_setup_fails_on_clone_error() -> Result<()> {
let temp_dir = TempDir::new()?;
let clone_path = temp_dir.path().join("clone-target");
let result = std::process::Command::new("git")
.args([
"clone",
"https://github.com/nonexistent-user-12345/nonexistent-repo-67890.git",
clone_path.to_str().unwrap(),
])
.output()?;
assert!(!result.status.success());
assert!(!clone_path.exists());
Ok(())
}
#[test]
fn setup_creates_all_required_files() -> Result<()> {
let env = TestEnv::new().with_profile("default").with_git().build()?;
assert!(env.config_path().exists(), "config.toml missing");
assert!(env.tracking_path().exists(), "symlinks.json missing");
assert!(
env.repo_path.join(".dotstate-profiles.toml").exists(),
"manifest missing"
);
assert!(env.repo_path.join(".git").exists(), "git repo missing");
assert!(
env.profile_path("default").exists(),
"default profile dir missing"
);
assert!(env.common_path().exists(), "common dir missing");
let config = env.load_config()?;
assert_eq!(config.active_profile, "default");
let manifest = env.load_manifest()?;
assert!(!manifest.profiles.is_empty());
let tracking = env.load_tracking()?;
assert_eq!(tracking.version, 1);
Ok(())
}
#[test]
fn setup_is_atomic_config_not_created_on_repo_failure() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_dir = temp_dir.path().join("config");
let repo_path = PathBuf::from("/this/path/should/fail");
let repo_created = fs::create_dir_all(&repo_path).is_ok();
if !repo_created {
assert!(!config_dir.join("config.toml").exists());
}
Ok(())
}
#[test]
fn setup_validates_backup_dir_is_writable() -> Result<()> {
let temp_dir = TempDir::new()?;
let backup_dir = temp_dir.path().join("backups");
fs::create_dir_all(&backup_dir)?;
let test_file = backup_dir.join(".write-test");
let can_write = fs::write(&test_file, "test").is_ok();
assert!(can_write);
let _ = fs::remove_file(&test_file);
Ok(())
}
#[test]
fn setup_preserves_user_preferences_on_reinit() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_dir = temp_dir.path().join("config");
fs::create_dir_all(&config_dir)?;
let existing_config = dotstate::config::Config {
repo_path: PathBuf::from("/old/path"),
active_profile: "old-profile".to_string(),
backup_enabled: false, theme: "light".to_string(), ..Default::default()
};
let content = toml::to_string_pretty(&existing_config)?;
fs::write(config_dir.join("config.toml"), content)?;
let loaded_content = fs::read_to_string(config_dir.join("config.toml"))?;
let loaded: dotstate::config::Config = toml::from_str(&loaded_content)?;
assert!(!loaded.backup_enabled);
assert_eq!(loaded.theme, "light");
Ok(())
}