use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
const CURRENT_VERSION: u32 = 2;
const MAX_INHERITANCE_DEPTH: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedFile {
pub relative_path: String,
pub source_profile: String,
}
impl ResolvedFile {
pub fn from_files(profile_name: &str, files: &[String]) -> Vec<Self> {
files
.iter()
.map(|f| Self {
relative_path: f.clone(),
source_profile: profile_name.to_string(),
})
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum PackageManager {
Brew, Apt, Yum, Dnf, Pacman, Snap, Cargo, Npm, Pip, Pip3, Gem, Custom, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Package {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub manager: PackageManager,
#[serde(default)]
pub package_name: Option<String>,
pub binary_name: String,
#[serde(default)]
pub install_command: Option<String>,
#[serde(default)]
pub existence_check: Option<String>,
#[serde(default)]
pub manager_check: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonSection {
#[serde(default)]
pub synced_files: Vec<String>,
}
pub const RESERVED_PROFILE_NAMES: &[&str] = &["common"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileManifest {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub common: CommonSection,
#[serde(default)]
pub profiles: Vec<ProfileInfo>,
}
impl Default for ProfileManifest {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
common: CommonSection::default(),
profiles: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileInfo {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inherits: Option<String>,
#[serde(default)]
pub synced_files: Vec<String>,
#[serde(default)]
pub packages: Vec<Package>,
}
impl ProfileManifest {
#[must_use]
pub fn manifest_path(repo_path: &Path) -> PathBuf {
repo_path.join(".dotstate-profiles.toml")
}
pub fn load(repo_path: &Path) -> Result<Self> {
let manifest_path = Self::manifest_path(repo_path);
if manifest_path.exists() {
let content = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read profile manifest: {manifest_path:?}"))?;
let mut manifest: ProfileManifest =
toml::from_str(&content).with_context(|| "Failed to parse profile manifest")?;
if manifest.version < CURRENT_VERSION {
let old_version = manifest.version;
tracing::info!(
"Migrating manifest from v{} to v{}",
old_version,
CURRENT_VERSION
);
manifest = Self::migrate(manifest)?;
super::migrate_file(&manifest_path, old_version, "toml", || {
manifest.save(repo_path)
})?;
}
manifest.common.synced_files.sort();
for profile in &mut manifest.profiles {
profile.synced_files.sort();
}
Ok(manifest)
} else {
Ok(Self::default())
}
}
pub fn backfill_from_repo(repo_path: &Path) -> Result<Self> {
let mut manifest = Self::default();
if let Ok(entries) = std::fs::read_dir(repo_path) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name.starts_with('.') || name == "target" || name == "node_modules" {
continue;
}
if let Ok(dir_entries) = std::fs::read_dir(&path) {
let has_files = dir_entries.into_iter().next().is_some();
if has_files {
if name == "common" {
if let Ok(common_files) = Self::scan_folder_files(&path) {
manifest.common.synced_files = common_files;
}
} else {
manifest.add_profile(name.to_string(), None);
}
}
}
}
}
Ok(manifest)
}
fn scan_folder_files(folder_path: &Path) -> Result<Vec<String>> {
let mut files = Vec::new();
Self::scan_folder_files_recursive(folder_path, folder_path, &mut files)?;
files.sort();
Ok(files)
}
fn scan_folder_files_recursive(
base_path: &Path,
current_path: &Path,
files: &mut Vec<String>,
) -> Result<()> {
if let Ok(entries) = std::fs::read_dir(current_path) {
for entry in entries.flatten() {
let path = entry.path();
let relative = path
.strip_prefix(base_path)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if path.is_dir() {
Self::scan_folder_files_recursive(base_path, &path, files)?;
} else {
files.push(relative);
}
}
}
Ok(())
}
#[allow(dead_code)] pub fn update_packages(&mut self, profile_name: &str, packages: Vec<Package>) -> Result<()> {
if let Some(profile) = self.profiles.iter_mut().find(|p| p.name == profile_name) {
profile.packages = packages;
Ok(())
} else {
Err(anyhow::anyhow!(
"Profile '{profile_name}' not found in manifest"
))
}
}
pub fn load_or_backfill(repo_path: &Path) -> Result<Self> {
let manifest_path = Self::manifest_path(repo_path);
if manifest_path.exists() {
Self::load(repo_path)
} else {
let manifest = Self::backfill_from_repo(repo_path)?;
if !manifest.profiles.is_empty() {
manifest.save(repo_path)?;
}
Ok(manifest)
}
}
pub fn save(&self, repo_path: &Path) -> Result<()> {
let manifest_path = Self::manifest_path(repo_path);
let temp_path = manifest_path.with_extension("toml.tmp");
let content =
toml::to_string_pretty(self).with_context(|| "Failed to serialize profile manifest")?;
std::fs::write(&temp_path, &content)
.with_context(|| format!("Failed to write temp manifest: {temp_path:?}"))?;
std::fs::rename(&temp_path, &manifest_path)
.with_context(|| format!("Failed to rename temp manifest to {manifest_path:?}"))?;
Ok(())
}
pub fn add_profile(&mut self, name: String, description: Option<String>) {
self.add_profile_with_inherits(name, description, None);
}
pub fn add_profile_with_inherits(
&mut self,
name: String,
description: Option<String>,
inherits: Option<String>,
) {
if !self.profiles.iter().any(|p| p.name == name) {
self.profiles.push(ProfileInfo {
name,
description,
inherits,
synced_files: Vec::new(),
packages: Vec::new(),
});
}
}
pub fn update_synced_files(
&mut self,
profile_name: &str,
synced_files: Vec<String>,
) -> Result<()> {
if let Some(profile) = self.profiles.iter_mut().find(|p| p.name == profile_name) {
let mut sorted_files = synced_files;
sorted_files.sort();
profile.synced_files = sorted_files;
Ok(())
} else {
Err(anyhow::anyhow!(
"Profile '{profile_name}' not found in manifest"
))
}
}
pub fn remove_profile(&mut self, name: &str) -> bool {
let initial_len = self.profiles.len();
self.profiles.retain(|p| p.name != name);
self.profiles.len() < initial_len
}
pub fn rename_profile(&mut self, old_name: &str, new_name: &str) -> Result<()> {
let mut found = false;
for profile in &mut self.profiles {
if profile.name == old_name {
profile.name = new_name.to_string();
found = true;
}
if profile.inherits.as_deref() == Some(old_name) {
profile.inherits = Some(new_name.to_string());
}
}
if found {
Ok(())
} else {
Err(anyhow::anyhow!(
"Profile '{old_name}' not found in manifest"
))
}
}
#[allow(dead_code)] #[must_use]
pub fn profile_names(&self) -> Vec<String> {
self.profiles.iter().map(|p| p.name.clone()).collect()
}
#[allow(dead_code)] #[must_use]
pub fn has_profile(&self, name: &str) -> bool {
self.profiles.iter().any(|p| p.name == name)
}
#[must_use]
pub fn is_reserved_name(name: &str) -> bool {
RESERVED_PROFILE_NAMES.contains(&name.to_lowercase().as_str())
}
pub fn add_common_file(&mut self, relative_path: &str) {
let path = relative_path.to_string();
if !self.common.synced_files.contains(&path) {
self.common.synced_files.push(path);
self.common.synced_files.sort();
}
}
pub fn remove_common_file(&mut self, relative_path: &str) -> bool {
let initial_len = self.common.synced_files.len();
self.common.synced_files.retain(|f| f != relative_path);
self.common.synced_files.len() < initial_len
}
#[must_use]
pub fn get_common_files(&self) -> &[String] {
&self.common.synced_files
}
#[must_use]
pub fn is_common_file(&self, relative_path: &str) -> bool {
self.common
.synced_files
.contains(&relative_path.to_string())
}
fn migrate(mut manifest: Self) -> Result<Self> {
if manifest.version == 0 {
manifest = Self::migrate_v0_to_v1(manifest)?;
}
if manifest.version == 1 {
manifest = Self::migrate_v1_to_v2(manifest)?;
}
Ok(manifest)
}
fn migrate_v0_to_v1(mut manifest: Self) -> Result<Self> {
tracing::debug!("Migrating manifest v0 -> v1");
manifest.version = 1;
Ok(manifest)
}
fn migrate_v1_to_v2(mut manifest: Self) -> Result<Self> {
tracing::debug!("Migrating manifest v1 -> v2 (adds profile inheritance support)");
manifest.version = 2;
Ok(manifest)
}
pub fn inheritance_chain(&self, profile_name: &str) -> Result<Vec<String>> {
let mut chain = Vec::new();
let mut visited = HashSet::new();
let mut current = profile_name.to_string();
loop {
if visited.contains(¤t) {
return Err(anyhow::anyhow!(
"Inheritance cycle detected: '{}' appears twice in chain: [{}]",
current,
chain.join(" -> ")
));
}
if chain.len() >= MAX_INHERITANCE_DEPTH {
return Err(anyhow::anyhow!(
"Inheritance chain too deep (max {MAX_INHERITANCE_DEPTH}): [{}]",
chain.join(" -> ")
));
}
visited.insert(current.clone());
chain.push(current.clone());
let profile = self
.profiles
.iter()
.find(|p| p.name == current)
.ok_or_else(|| {
anyhow::anyhow!(
"Profile '{}' not found in manifest (referenced in inheritance chain: [{}])",
current,
chain.join(" -> ")
)
})?;
match &profile.inherits {
Some(parent) => {
current = parent.clone();
}
None => break,
}
}
Ok(chain)
}
pub fn resolve_files(&self, profile_name: &str) -> Result<Vec<ResolvedFile>> {
let chain = self.inheritance_chain(profile_name)?;
let mut file_map: HashMap<String, String> = HashMap::new();
for profile_name in chain.iter().rev() {
if let Some(profile) = self.profiles.iter().find(|p| &p.name == profile_name) {
for file in &profile.synced_files {
file_map.insert(file.clone(), profile_name.clone());
}
}
}
for file in &self.common.synced_files {
file_map
.entry(file.clone())
.or_insert_with(|| "common".to_string());
}
let mut resolved: Vec<ResolvedFile> = file_map
.into_iter()
.map(|(relative_path, source_profile)| ResolvedFile {
relative_path,
source_profile,
})
.collect();
resolved.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
Ok(resolved)
}
pub fn resolve_packages(&self, profile_name: &str) -> Result<Vec<Package>> {
let chain = self.inheritance_chain(profile_name)?;
let mut pkg_map: HashMap<(String, PackageManager), Package> = HashMap::new();
for profile_name in chain.iter().rev() {
if let Some(profile) = self.profiles.iter().find(|p| &p.name == profile_name) {
for pkg in &profile.packages {
let key = (pkg.name.clone(), pkg.manager.clone());
pkg_map.insert(key, pkg.clone());
}
}
}
let mut packages: Vec<Package> = pkg_map.into_values().collect();
packages.sort_by(|a, b| a.name.cmp(&b.name));
Ok(packages)
}
pub fn validate_inheritance(&self) -> Result<()> {
for profile in &self.profiles {
if let Some(parent_name) = &profile.inherits {
if !self.profiles.iter().any(|p| p.name == *parent_name) {
return Err(anyhow::anyhow!(
"Profile '{}' inherits from '{}', which does not exist",
profile.name,
parent_name
));
}
self.inheritance_chain(&profile.name)?;
}
}
Ok(())
}
#[must_use]
pub fn get_inheriting_profiles(&self, profile_name: &str) -> Vec<String> {
self.profiles
.iter()
.filter(|p| p.inherits.as_deref() == Some(profile_name))
.map(|p| p.name.clone())
.collect()
}
pub fn set_inherits(&mut self, profile_name: &str, inherits: Option<String>) -> Result<()> {
if let Some(profile) = self.profiles.iter_mut().find(|p| p.name == profile_name) {
let old_inherits = profile.inherits.clone();
profile.inherits = inherits;
if let Err(e) = self.validate_inheritance() {
if let Some(p) = self.profiles.iter_mut().find(|p| p.name == profile_name) {
p.inherits = old_inherits;
}
return Err(e);
}
Ok(())
} else {
Err(anyhow::anyhow!(
"Profile '{profile_name}' not found in manifest"
))
}
}
pub fn move_to_common(&mut self, profile_name: &str, relative_path: &str) -> Result<()> {
if let Some(profile) = self.profiles.iter_mut().find(|p| p.name == profile_name) {
profile.synced_files.retain(|f| f != relative_path);
} else {
return Err(anyhow::anyhow!(
"Profile '{profile_name}' not found in manifest"
));
}
self.add_common_file(relative_path);
Ok(())
}
pub fn move_from_common(&mut self, profile_name: &str, relative_path: &str) -> Result<()> {
if !self.remove_common_file(relative_path) {
return Err(anyhow::anyhow!(
"File '{relative_path}' not found in common section"
));
}
if let Some(profile) = self.profiles.iter_mut().find(|p| p.name == profile_name) {
if !profile.synced_files.contains(&relative_path.to_string()) {
profile.synced_files.push(relative_path.to_string());
profile.synced_files.sort();
}
Ok(())
} else {
Err(anyhow::anyhow!(
"Profile '{profile_name}' not found in manifest"
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_profile_manifest() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let mut manifest = ProfileManifest::default();
manifest.add_profile("Personal".to_string(), Some("Personal Mac".to_string()));
manifest.add_profile("Work".to_string(), None);
let packages = vec![Package {
name: "eza".to_string(),
description: Some("Modern replacement for ls".to_string()),
manager: PackageManager::Brew,
package_name: Some("eza".to_string()),
binary_name: "eza".to_string(),
install_command: None,
existence_check: None,
manager_check: None,
}];
manifest.update_packages("Personal", packages).unwrap();
manifest.save(repo_path).unwrap();
let mut loaded = ProfileManifest::load(repo_path).unwrap();
assert_eq!(loaded.profiles.len(), 2);
assert!(loaded.has_profile("Personal"));
assert!(loaded.has_profile("Work"));
loaded.rename_profile("Personal", "Personal-Mac").unwrap();
assert!(!loaded.has_profile("Personal"));
assert!(loaded.has_profile("Personal-Mac"));
loaded.remove_profile("Work");
assert!(!loaded.has_profile("Work"));
}
#[test]
fn test_reserved_names() {
assert!(ProfileManifest::is_reserved_name("common"));
assert!(ProfileManifest::is_reserved_name("Common"));
assert!(ProfileManifest::is_reserved_name("COMMON"));
assert!(!ProfileManifest::is_reserved_name("work"));
assert!(!ProfileManifest::is_reserved_name("personal"));
}
#[test]
fn test_common_files() {
let mut manifest = ProfileManifest::default();
manifest.add_common_file(".gitconfig");
manifest.add_common_file(".tmux.conf");
assert_eq!(manifest.get_common_files().len(), 2);
assert!(manifest.is_common_file(".gitconfig"));
assert!(manifest.is_common_file(".tmux.conf"));
manifest.add_common_file(".gitconfig");
assert_eq!(manifest.get_common_files().len(), 2);
assert!(manifest.remove_common_file(".tmux.conf"));
assert_eq!(manifest.get_common_files().len(), 1);
assert!(!manifest.is_common_file(".tmux.conf"));
assert!(!manifest.remove_common_file(".nonexistent"));
}
#[test]
fn test_move_to_common() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("work".to_string(), None);
manifest
.update_synced_files("work", vec![".zshrc".to_string()])
.unwrap();
manifest.move_to_common("work", ".zshrc").unwrap();
assert!(manifest.is_common_file(".zshrc"));
let profile = manifest.profiles.iter().find(|p| p.name == "work").unwrap();
assert!(!profile.synced_files.contains(&".zshrc".to_string()));
}
#[test]
fn test_move_from_common() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("work".to_string(), None);
manifest.add_common_file(".gitconfig");
manifest.move_from_common("work", ".gitconfig").unwrap();
assert!(!manifest.is_common_file(".gitconfig"));
let profile = manifest.profiles.iter().find(|p| p.name == "work").unwrap();
assert!(profile.synced_files.contains(&".gitconfig".to_string()));
}
#[test]
fn test_manifest_migration_v0_to_v1() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let v0_manifest = r#"
[common]
synced_files = [".gitconfig"]
[[profiles]]
name = "work"
synced_files = [".zshrc"]
"#;
std::fs::write(ProfileManifest::manifest_path(repo_path), v0_manifest).unwrap();
let loaded = ProfileManifest::load(repo_path).unwrap();
assert_eq!(loaded.version, CURRENT_VERSION);
assert!(loaded.is_common_file(".gitconfig"));
assert!(loaded.has_profile("work"));
let content = std::fs::read_to_string(ProfileManifest::manifest_path(repo_path)).unwrap();
assert!(content.contains(&format!("version = {CURRENT_VERSION}")));
let backup_path =
ProfileManifest::manifest_path(repo_path).with_extension("toml.backup-v0");
assert!(!backup_path.exists());
}
#[test]
fn test_manifest_already_at_current_version() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let current_manifest = format!(
r#"
version = {CURRENT_VERSION}
[common]
synced_files = []
[[profiles]]
name = "test"
synced_files = []
"#
);
std::fs::write(ProfileManifest::manifest_path(repo_path), current_manifest).unwrap();
let loaded = ProfileManifest::load(repo_path).unwrap();
assert_eq!(loaded.version, CURRENT_VERSION);
let backup_path =
ProfileManifest::manifest_path(repo_path).with_extension("toml.backup-v0");
assert!(!backup_path.exists());
}
#[test]
fn test_new_manifest_has_current_version() {
let manifest = ProfileManifest::default();
assert_eq!(manifest.version, CURRENT_VERSION);
}
#[test]
fn test_manifest_migration_v1_to_v2() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let v1_manifest = r#"
version = 1
[common]
synced_files = [".gitconfig"]
[[profiles]]
name = "work"
synced_files = [".zshrc"]
"#;
std::fs::write(ProfileManifest::manifest_path(repo_path), v1_manifest).unwrap();
let loaded = ProfileManifest::load(repo_path).unwrap();
assert_eq!(loaded.version, 2);
assert!(loaded.is_common_file(".gitconfig"));
assert!(loaded.has_profile("work"));
let work = loaded.profiles.iter().find(|p| p.name == "work").unwrap();
assert!(work.inherits.is_none());
}
#[test]
fn test_inherits_field_serialization() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let mut manifest = ProfileManifest::default();
manifest.add_profile("base".to_string(), None);
manifest.add_profile_with_inherits(
"child".to_string(),
Some("Child profile".to_string()),
Some("base".to_string()),
);
manifest.save(repo_path).unwrap();
let loaded = ProfileManifest::load(repo_path).unwrap();
let base = loaded.profiles.iter().find(|p| p.name == "base").unwrap();
assert!(base.inherits.is_none());
let child = loaded.profiles.iter().find(|p| p.name == "child").unwrap();
assert_eq!(child.inherits, Some("base".to_string()));
}
#[test]
fn test_inheritance_chain_simple() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("grandparent".to_string(), None);
manifest.add_profile_with_inherits(
"parent".to_string(),
None,
Some("grandparent".to_string()),
);
manifest.add_profile_with_inherits("child".to_string(), None, Some("parent".to_string()));
let chain = manifest.inheritance_chain("child").unwrap();
assert_eq!(chain, vec!["child", "parent", "grandparent"]);
}
#[test]
fn test_inheritance_chain_no_parent() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("standalone".to_string(), None);
let chain = manifest.inheritance_chain("standalone").unwrap();
assert_eq!(chain, vec!["standalone"]);
}
#[test]
fn test_inheritance_cycle_detection() {
let mut manifest = ProfileManifest::default();
manifest.profiles.push(ProfileInfo {
name: "a".to_string(),
description: None,
inherits: Some("b".to_string()),
synced_files: Vec::new(),
packages: Vec::new(),
});
manifest.profiles.push(ProfileInfo {
name: "b".to_string(),
description: None,
inherits: Some("a".to_string()),
synced_files: Vec::new(),
packages: Vec::new(),
});
let result = manifest.inheritance_chain("a");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cycle"));
}
#[test]
fn test_inheritance_missing_parent() {
let mut manifest = ProfileManifest::default();
manifest.profiles.push(ProfileInfo {
name: "orphan".to_string(),
description: None,
inherits: Some("nonexistent".to_string()),
synced_files: Vec::new(),
packages: Vec::new(),
});
let result = manifest.inheritance_chain("orphan");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_resolve_files_with_inheritance() {
let mut manifest = ProfileManifest::default();
manifest.common.synced_files = vec![".gitconfig".to_string(), ".tmux.conf".to_string()];
manifest.profiles.push(ProfileInfo {
name: "p1".to_string(),
description: None,
inherits: None,
synced_files: vec![".zshrc".to_string(), ".vimrc".to_string()],
packages: Vec::new(),
});
manifest.profiles.push(ProfileInfo {
name: "p2".to_string(),
description: None,
inherits: Some("p1".to_string()),
synced_files: vec![".vimrc".to_string(), ".config/nvim".to_string()],
packages: Vec::new(),
});
let resolved = manifest.resolve_files("p2").unwrap();
assert_eq!(resolved.len(), 5);
let find = |path: &str| resolved.iter().find(|r| r.relative_path == path).unwrap();
assert_eq!(find(".config/nvim").source_profile, "p2");
assert_eq!(find(".gitconfig").source_profile, "common");
assert_eq!(find(".tmux.conf").source_profile, "common");
assert_eq!(find(".vimrc").source_profile, "p2"); assert_eq!(find(".zshrc").source_profile, "p1"); }
#[test]
fn test_resolve_files_profile_overrides_common() {
let mut manifest = ProfileManifest::default();
manifest.common.synced_files = vec![".gitconfig".to_string()];
manifest.profiles.push(ProfileInfo {
name: "p1".to_string(),
description: None,
inherits: None,
synced_files: vec![".gitconfig".to_string()], packages: Vec::new(),
});
let resolved = manifest.resolve_files("p1").unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].source_profile, "p1"); }
#[test]
fn test_resolve_files_no_inheritance() {
let mut manifest = ProfileManifest::default();
manifest.common.synced_files = vec![".gitconfig".to_string()];
manifest.profiles.push(ProfileInfo {
name: "standalone".to_string(),
description: None,
inherits: None,
synced_files: vec![".zshrc".to_string()],
packages: Vec::new(),
});
let resolved = manifest.resolve_files("standalone").unwrap();
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");
}
#[test]
fn test_resolve_packages_with_inheritance() {
let mut manifest = ProfileManifest::default();
let eza_pkg = Package {
name: "eza".to_string(),
description: Some("ls replacement".to_string()),
manager: PackageManager::Brew,
package_name: Some("eza".to_string()),
binary_name: "eza".to_string(),
install_command: None,
existence_check: None,
manager_check: None,
};
let bat_pkg = Package {
name: "bat".to_string(),
description: Some("cat replacement".to_string()),
manager: PackageManager::Brew,
package_name: Some("bat".to_string()),
binary_name: "bat".to_string(),
install_command: None,
existence_check: None,
manager_check: None,
};
let fzf_pkg = Package {
name: "fzf".to_string(),
description: Some("fuzzy finder".to_string()),
manager: PackageManager::Brew,
package_name: Some("fzf".to_string()),
binary_name: "fzf".to_string(),
install_command: None,
existence_check: None,
manager_check: None,
};
manifest.profiles.push(ProfileInfo {
name: "p1".to_string(),
description: None,
inherits: None,
synced_files: Vec::new(),
packages: vec![eza_pkg.clone(), bat_pkg],
});
manifest.profiles.push(ProfileInfo {
name: "p2".to_string(),
description: None,
inherits: Some("p1".to_string()),
synced_files: Vec::new(),
packages: vec![fzf_pkg],
});
let packages = manifest.resolve_packages("p2").unwrap();
assert_eq!(packages.len(), 3);
let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"eza"));
assert!(names.contains(&"bat"));
assert!(names.contains(&"fzf"));
}
#[test]
fn test_validate_inheritance_valid() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("base".to_string(), None);
manifest.add_profile_with_inherits("child".to_string(), None, Some("base".to_string()));
assert!(manifest.validate_inheritance().is_ok());
}
#[test]
fn test_validate_inheritance_missing_parent() {
let mut manifest = ProfileManifest::default();
manifest.profiles.push(ProfileInfo {
name: "orphan".to_string(),
description: None,
inherits: Some("ghost".to_string()),
synced_files: Vec::new(),
packages: Vec::new(),
});
assert!(manifest.validate_inheritance().is_err());
}
#[test]
fn test_validate_inheritance_cycle() {
let mut manifest = ProfileManifest::default();
manifest.profiles.push(ProfileInfo {
name: "a".to_string(),
description: None,
inherits: Some("b".to_string()),
synced_files: Vec::new(),
packages: Vec::new(),
});
manifest.profiles.push(ProfileInfo {
name: "b".to_string(),
description: None,
inherits: Some("a".to_string()),
synced_files: Vec::new(),
packages: Vec::new(),
});
assert!(manifest.validate_inheritance().is_err());
}
#[test]
fn test_set_inherits() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("base".to_string(), None);
manifest.add_profile("child".to_string(), None);
manifest
.set_inherits("child", Some("base".to_string()))
.unwrap();
assert_eq!(
manifest
.profiles
.iter()
.find(|p| p.name == "child")
.unwrap()
.inherits,
Some("base".to_string())
);
manifest.set_inherits("child", None).unwrap();
assert!(manifest
.profiles
.iter()
.find(|p| p.name == "child")
.unwrap()
.inherits
.is_none());
}
#[test]
fn test_set_inherits_cycle_prevention() {
let mut manifest = ProfileManifest::default();
manifest.add_profile_with_inherits("a".to_string(), None, Some("b".to_string()));
manifest.add_profile("b".to_string(), None);
let result = manifest.set_inherits("b", Some("a".to_string()));
assert!(result.is_err());
assert!(manifest
.profiles
.iter()
.find(|p| p.name == "b")
.unwrap()
.inherits
.is_none());
}
#[test]
fn test_get_inheriting_profiles() {
let mut manifest = ProfileManifest::default();
manifest.add_profile("base".to_string(), None);
manifest.add_profile_with_inherits("child1".to_string(), None, Some("base".to_string()));
manifest.add_profile_with_inherits("child2".to_string(), None, Some("base".to_string()));
manifest.add_profile("standalone".to_string(), None);
let children = manifest.get_inheriting_profiles("base");
assert_eq!(children.len(), 2);
assert!(children.contains(&"child1".to_string()));
assert!(children.contains(&"child2".to_string()));
assert!(manifest.get_inheriting_profiles("standalone").is_empty());
}
#[test]
fn test_three_level_inheritance() {
let mut manifest = ProfileManifest::default();
manifest.common.synced_files = vec![".gitconfig".to_string()];
manifest.profiles.push(ProfileInfo {
name: "grandparent".to_string(),
description: None,
inherits: None,
synced_files: vec![".zshrc".to_string(), ".bashrc".to_string()],
packages: Vec::new(),
});
manifest.profiles.push(ProfileInfo {
name: "parent".to_string(),
description: None,
inherits: Some("grandparent".to_string()),
synced_files: vec![".zshrc".to_string(), ".vimrc".to_string()], packages: Vec::new(),
});
manifest.profiles.push(ProfileInfo {
name: "child".to_string(),
description: None,
inherits: Some("parent".to_string()),
synced_files: vec![".config/nvim".to_string()], packages: Vec::new(),
});
let resolved = manifest.resolve_files("child").unwrap();
assert_eq!(resolved.len(), 5);
let find = |path: &str| resolved.iter().find(|r| r.relative_path == path).unwrap();
assert_eq!(find(".bashrc").source_profile, "grandparent"); assert_eq!(find(".config/nvim").source_profile, "child"); assert_eq!(find(".gitconfig").source_profile, "common"); assert_eq!(find(".vimrc").source_profile, "parent"); assert_eq!(find(".zshrc").source_profile, "parent"); }
}