use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use crate::config::{Config, DEFAULT_PROFILE};
use crate::error::{Error, Result};
static PROFILE_NAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+$").expect("Invalid regex pattern"));
pub struct Profile {
pub dir: PathBuf,
pub flake_path: PathBuf,
pub packages_dir: PathBuf,
}
impl Profile {
pub fn new(name: &str, config: &Config) -> Self {
let dir = config.profiles_dir.join(name);
Self {
flake_path: dir.join("flake.nix"),
packages_dir: dir.join("packages"),
dir,
}
}
pub fn exists(&self) -> bool {
self.dir.exists()
}
pub fn create(&self) -> Result<()> {
fs::create_dir_all(&self.dir)?;
Ok(())
}
pub fn delete(&self) -> Result<()> {
if self.dir.exists() {
fs::remove_dir_all(&self.dir)?;
}
Ok(())
}
}
pub fn get_active_profile(config: &Config) -> String {
if config.active_file.exists() {
fs::read_to_string(&config.active_file)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| DEFAULT_PROFILE.to_string())
} else {
DEFAULT_PROFILE.to_string()
}
}
pub fn set_active_profile(config: &Config, name: &str) -> Result<()> {
fs::create_dir_all(&config.config_dir)?;
fs::write(&config.active_file, name)?;
Ok(())
}
pub fn validate_profile_name(name: &str) -> Result<()> {
if !PROFILE_NAME_REGEX.is_match(name) {
return Err(Error::InvalidProfileName(name.to_string()));
}
Ok(())
}
pub fn list_profiles(config: &Config) -> Result<Vec<String>> {
let mut profiles = Vec::new();
if config.profiles_dir.exists() {
for entry in fs::read_dir(&config.profiles_dir)? {
let entry = entry?;
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
profiles.push(name.to_string());
}
}
}
}
profiles.sort();
Ok(profiles)
}
pub fn get_flake_path(config: &Config) -> PathBuf {
let active = get_active_profile(config);
let profile = Profile::new(&active, config);
if profile.flake_path.exists() {
return profile.flake_path;
}
if active == DEFAULT_PROFILE && config.legacy_flake.exists() {
return config.legacy_flake.clone();
}
profile.flake_path
}
pub fn get_flake_dir(config: &Config) -> Result<PathBuf> {
let flake_path = get_flake_path(config);
if flake_path.is_symlink() {
let target = fs::read_link(&flake_path)?;
let resolved = if target.is_absolute() {
target
} else {
match flake_path.parent() {
Some(parent) => parent.join(&target),
None => target,
}
};
let parent = match resolved.parent() {
Some(p) => p.to_path_buf(),
None => resolved.clone(),
};
if parent.exists() {
Ok(fs::canonicalize(&parent)?)
} else {
Ok(parent)
}
} else {
let dir = flake_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Ok(dir)
}
}
pub fn has_legacy_flake(config: &Config) -> bool {
config.legacy_flake.exists() && !config.profiles_dir.join(DEFAULT_PROFILE).exists()
}
pub fn migrate_legacy_flake(config: &Config) -> Result<()> {
let profile = Profile::new(DEFAULT_PROFILE, config);
profile.create()?;
fs::copy(&config.legacy_flake, &profile.flake_path)?;
let legacy_lock = config.config_dir.join("flake.lock");
if legacy_lock.exists() {
fs::copy(&legacy_lock, profile.dir.join("flake.lock"))?;
}
let legacy_packages = config.config_dir.join("packages");
if legacy_packages.exists() {
copy_dir_recursive(&legacy_packages, &profile.packages_dir)?;
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config(temp: &TempDir) -> Config {
Config {
config_dir: temp.path().join("config"),
profiles_dir: temp.path().join("config/profiles"),
active_file: temp.path().join("config/active"),
env_link: temp.path().join("env"),
legacy_flake: temp.path().join("config/flake.nix"),
}
}
#[test]
fn test_validate_profile_name_valid() {
assert!(validate_profile_name("default").is_ok());
assert!(validate_profile_name("work").is_ok());
assert!(validate_profile_name("my-profile").is_ok());
assert!(validate_profile_name("profile_123").is_ok());
assert!(validate_profile_name("Profile-Test_123").is_ok());
}
#[test]
fn test_validate_profile_name_invalid() {
assert!(validate_profile_name("invalid name").is_err());
assert!(validate_profile_name("invalid!name").is_err());
assert!(validate_profile_name("invalid@name").is_err());
assert!(validate_profile_name("invalid/name").is_err());
assert!(validate_profile_name("").is_err());
}
#[test]
fn test_get_active_profile_default() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
let active = get_active_profile(&config);
assert_eq!(active, DEFAULT_PROFILE);
}
#[test]
fn test_get_active_profile_custom() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
fs::create_dir_all(&config.config_dir).unwrap();
fs::write(&config.active_file, "work").unwrap();
let active = get_active_profile(&config);
assert_eq!(active, "work");
}
#[test]
fn test_set_active_profile() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
set_active_profile(&config, "work").unwrap();
let content = fs::read_to_string(&config.active_file).unwrap();
assert_eq!(content, "work");
}
#[test]
fn test_profile_create_and_exists() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
let profile = Profile::new("test", &config);
assert!(!profile.exists());
profile.create().unwrap();
assert!(profile.exists());
assert!(profile.dir.exists());
}
#[test]
fn test_profile_delete() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
let profile = Profile::new("test", &config);
profile.create().unwrap();
assert!(profile.exists());
profile.delete().unwrap();
assert!(!profile.exists());
}
#[test]
fn test_list_profiles_empty() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
let profiles = list_profiles(&config).unwrap();
assert!(profiles.is_empty());
}
#[test]
fn test_list_profiles_multiple() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
let profile1 = Profile::new("work", &config);
let profile2 = Profile::new("personal", &config);
let profile3 = Profile::new("default", &config);
profile1.create().unwrap();
profile2.create().unwrap();
profile3.create().unwrap();
let profiles = list_profiles(&config).unwrap();
assert_eq!(profiles.len(), 3);
assert_eq!(profiles, vec!["default", "personal", "work"]);
}
#[test]
fn test_has_legacy_flake() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
assert!(!has_legacy_flake(&config));
fs::create_dir_all(&config.config_dir).unwrap();
fs::write(&config.legacy_flake, "{}").unwrap();
assert!(has_legacy_flake(&config));
let default_profile = Profile::new(DEFAULT_PROFILE, &config);
default_profile.create().unwrap();
assert!(!has_legacy_flake(&config));
}
#[test]
fn test_get_flake_path_profile() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
let profile = Profile::new(DEFAULT_PROFILE, &config);
profile.create().unwrap();
fs::write(&profile.flake_path, "{}").unwrap();
let flake_path = get_flake_path(&config);
assert_eq!(flake_path, profile.flake_path);
}
#[test]
fn test_get_flake_path_legacy_fallback() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
fs::create_dir_all(&config.config_dir).unwrap();
fs::write(&config.legacy_flake, "{}").unwrap();
let flake_path = get_flake_path(&config);
assert_eq!(flake_path, config.legacy_flake);
}
#[test]
fn test_migrate_legacy_flake() {
let temp = TempDir::new().unwrap();
let config = test_config(&temp);
fs::create_dir_all(&config.config_dir).unwrap();
fs::write(&config.legacy_flake, "{ legacy = true; }").unwrap();
fs::write(config.config_dir.join("flake.lock"), "{}").unwrap();
let legacy_packages = config.config_dir.join("packages");
fs::create_dir_all(&legacy_packages).unwrap();
fs::write(legacy_packages.join("test.nix"), "{}").unwrap();
migrate_legacy_flake(&config).unwrap();
let profile = Profile::new(DEFAULT_PROFILE, &config);
assert!(profile.flake_path.exists());
assert!(profile.dir.join("flake.lock").exists());
assert!(profile.packages_dir.join("test.nix").exists());
let content = fs::read_to_string(&profile.flake_path).unwrap();
assert!(content.contains("legacy = true"));
}
}