use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid profile symlink")]
InvalidProfile,
#[error("generation {0} not found")]
GenerationNotFound(u32),
#[error("no current generation")]
NoCurrentGeneration,
#[error("no previous generation to rollback to")]
NoPreviousGeneration,
}
#[derive(Debug, Clone)]
pub struct Generation {
pub number: u32,
pub path: PathBuf,
pub created: Option<std::time::SystemTime>,
pub current: bool,
}
pub struct ProfileManager {
profile_dir: PathBuf,
profile_name: String,
}
impl ProfileManager {
pub fn new(profile_dir: impl Into<PathBuf>, name: impl Into<String>) -> Self {
Self {
profile_dir: profile_dir.into(),
profile_name: name.into(),
}
}
#[must_use]
pub fn system() -> Self {
Self::new("/nix/var/nix/profiles", "system")
}
#[must_use]
pub fn profile_path(&self) -> PathBuf {
self.profile_dir.join(&self.profile_name)
}
fn generation_link(&self, gen_num: u32) -> PathBuf {
self.profile_dir
.join(format!("{}-{}-link", self.profile_name, gen_num))
}
pub fn current_generation(&self) -> Result<Option<u32>, ProfileError> {
let profile = self.profile_path();
if !profile.exists() {
return Ok(None);
}
let target = std::fs::read_link(&profile)?;
let filename = target
.file_name()
.and_then(|f| f.to_str())
.ok_or(ProfileError::InvalidProfile)?;
let number = parse_generation_number(filename, &self.profile_name)?;
Ok(Some(number))
}
pub fn list_generations(&self) -> Result<Vec<Generation>, ProfileError> {
if !self.profile_dir.exists() {
return Ok(Vec::new());
}
let mut generations = Vec::new();
let current = self.current_generation()?;
let prefix = format!("{}-", self.profile_name);
let suffix = "-link";
for entry in std::fs::read_dir(&self.profile_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(&prefix) || !name_str.ends_with(suffix) {
continue;
}
if name_str.ends_with("-tmp-link") {
continue;
}
let mid = &name_str[prefix.len()..name_str.len() - suffix.len()];
if let Ok(num) = mid.parse::<u32>() {
let path = std::fs::read_link(entry.path())?;
let created = entry.metadata().ok().and_then(|m| m.created().ok());
generations.push(Generation {
number: num,
path,
created,
current: current == Some(num),
});
}
}
generations.sort_by_key(|g| g.number);
Ok(generations)
}
pub fn set(&self, store_path: &Path) -> Result<u32, ProfileError> {
std::fs::create_dir_all(&self.profile_dir)?;
let next = self.next_generation_number()?;
let link = self.generation_link(next);
std::os::unix::fs::symlink(store_path, &link)?;
self.atomic_switch(&link)?;
Ok(next)
}
pub fn switch_generation(&self, gen_num: u32) -> Result<(), ProfileError> {
let link = self.generation_link(gen_num);
if !link.exists() {
return Err(ProfileError::GenerationNotFound(gen_num));
}
self.atomic_switch(&link)?;
Ok(())
}
pub fn rollback(&self) -> Result<u32, ProfileError> {
let current = self
.current_generation()?
.ok_or(ProfileError::NoCurrentGeneration)?;
let generations = self.list_generations()?;
let prev = generations
.iter()
.filter(|g| g.number < current)
.max_by_key(|g| g.number)
.ok_or(ProfileError::NoPreviousGeneration)?;
self.switch_generation(prev.number)?;
Ok(prev.number)
}
pub fn delete_generation(&self, gen_num: u32) -> Result<(), ProfileError> {
let current = self.current_generation()?;
if current == Some(gen_num) {
return Err(ProfileError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"cannot delete the current generation",
)));
}
let link = self.generation_link(gen_num);
if !link.exists() {
return Err(ProfileError::GenerationNotFound(gen_num));
}
std::fs::remove_file(&link)?;
Ok(())
}
fn atomic_switch(&self, target: &Path) -> Result<(), ProfileError> {
let profile = self.profile_path();
let tmp = self
.profile_dir
.join(format!("{}-tmp-link", self.profile_name));
let _ = std::fs::remove_file(&tmp);
std::os::unix::fs::symlink(target, &tmp)?;
std::fs::rename(&tmp, &profile)?; Ok(())
}
fn next_generation_number(&self) -> Result<u32, ProfileError> {
let generations = self.list_generations()?;
Ok(generations.last().map(|g| g.number + 1).unwrap_or(1))
}
}
fn parse_generation_number(filename: &str, profile_name: &str) -> Result<u32, ProfileError> {
let prefix = format!("{profile_name}-");
let suffix = "-link";
if !filename.starts_with(&prefix) || !filename.ends_with(suffix) {
return Err(ProfileError::InvalidProfile);
}
let mid = &filename[prefix.len()..filename.len() - suffix.len()];
mid.parse().map_err(|_| ProfileError::InvalidProfile)
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_store_path(tmp: &Path, name: &str) -> PathBuf {
let store = tmp.join("store");
let p = store.join(name);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn set_creates_generation_and_profile_symlink() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "system");
let store_path = fake_store_path(tmp.path(), "abc123-foo");
let gen_num = pm.set(&store_path).unwrap();
assert_eq!(gen_num, 1);
let profile = pm.profile_path();
assert!(profile.is_symlink());
let gen_link = pm.generation_link(1);
assert!(gen_link.is_symlink());
let gen_target = std::fs::read_link(&gen_link).unwrap();
assert_eq!(gen_target, store_path);
}
#[test]
fn current_generation_returns_correct_number() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "system");
let sp1 = fake_store_path(tmp.path(), "gen1-path");
let sp2 = fake_store_path(tmp.path(), "gen2-path");
pm.set(&sp1).unwrap();
assert_eq!(pm.current_generation().unwrap(), Some(1));
pm.set(&sp2).unwrap();
assert_eq!(pm.current_generation().unwrap(), Some(2));
}
#[test]
fn list_generations_returns_all_sorted() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "test");
let sp1 = fake_store_path(tmp.path(), "g1");
let sp2 = fake_store_path(tmp.path(), "g2");
let sp3 = fake_store_path(tmp.path(), "g3");
pm.set(&sp1).unwrap();
pm.set(&sp2).unwrap();
pm.set(&sp3).unwrap();
let generations = pm.list_generations().unwrap();
assert_eq!(generations.len(), 3);
assert_eq!(generations[0].number, 1);
assert_eq!(generations[1].number, 2);
assert_eq!(generations[2].number, 3);
assert!(!generations[0].current);
assert!(!generations[1].current);
assert!(generations[2].current);
}
#[test]
fn switch_generation_updates_profile() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "myprofile");
let sp1 = fake_store_path(tmp.path(), "first");
let sp2 = fake_store_path(tmp.path(), "second");
pm.set(&sp1).unwrap();
pm.set(&sp2).unwrap();
assert_eq!(pm.current_generation().unwrap(), Some(2));
pm.switch_generation(1).unwrap();
assert_eq!(pm.current_generation().unwrap(), Some(1));
}
#[test]
fn rollback_switches_to_previous() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "sys");
let sp1 = fake_store_path(tmp.path(), "a");
let sp2 = fake_store_path(tmp.path(), "b");
let sp3 = fake_store_path(tmp.path(), "c");
pm.set(&sp1).unwrap();
pm.set(&sp2).unwrap();
pm.set(&sp3).unwrap();
assert_eq!(pm.current_generation().unwrap(), Some(3));
let prev = pm.rollback().unwrap();
assert_eq!(prev, 2);
assert_eq!(pm.current_generation().unwrap(), Some(2));
}
#[test]
fn multiple_sets_increment_generations() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "default");
for i in 1..=5 {
let sp = fake_store_path(tmp.path(), &format!("generation-{i}"));
let number = pm.set(&sp).unwrap();
assert_eq!(number, i);
}
}
#[test]
fn highest_generation_not_current_after_switch() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "test");
let sp1 = fake_store_path(tmp.path(), "x1");
let sp2 = fake_store_path(tmp.path(), "x2");
let sp3 = fake_store_path(tmp.path(), "x3");
pm.set(&sp1).unwrap();
pm.set(&sp2).unwrap();
pm.set(&sp3).unwrap();
pm.switch_generation(1).unwrap();
let generations = pm.list_generations().unwrap();
assert_eq!(generations.len(), 3);
assert!(generations[0].current); assert!(!generations[1].current);
assert!(!generations[2].current); }
#[test]
fn empty_profile_dir_returns_none_and_empty_list() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("empty-profiles");
let pm = ProfileManager::new(&profiles_dir, "system");
assert_eq!(pm.current_generation().unwrap(), None);
assert!(pm.list_generations().unwrap().is_empty());
}
#[test]
fn set_does_not_leave_tmp_symlink() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "atomic");
let sp = fake_store_path(tmp.path(), "store-path");
pm.set(&sp).unwrap();
let tmp_link = profiles_dir.join("atomic-tmp-link");
assert!(!tmp_link.exists());
}
#[test]
fn rollback_from_first_generation_errors() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "sys");
let sp = fake_store_path(tmp.path(), "only");
pm.set(&sp).unwrap();
let result = pm.rollback();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ProfileError::NoPreviousGeneration
));
}
#[test]
fn rollback_with_no_profile_errors() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "sys");
let result = pm.rollback();
assert!(matches!(
result.unwrap_err(),
ProfileError::NoCurrentGeneration
));
}
#[test]
fn switch_to_nonexistent_generation_errors() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
std::fs::create_dir_all(&profiles_dir).unwrap();
let pm = ProfileManager::new(&profiles_dir, "test");
let result = pm.switch_generation(99);
assert!(matches!(
result.unwrap_err(),
ProfileError::GenerationNotFound(99)
));
}
#[test]
fn delete_generation_removes_link() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "test");
let sp1 = fake_store_path(tmp.path(), "d1");
let sp2 = fake_store_path(tmp.path(), "d2");
pm.set(&sp1).unwrap();
pm.set(&sp2).unwrap();
pm.delete_generation(1).unwrap();
let generations = pm.list_generations().unwrap();
assert_eq!(generations.len(), 1);
assert_eq!(generations[0].number, 2);
}
#[test]
fn delete_current_generation_errors() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "test");
let sp = fake_store_path(tmp.path(), "current");
pm.set(&sp).unwrap();
let result = pm.delete_generation(1);
assert!(result.is_err());
}
#[test]
fn generation_paths_are_correct() {
let tmp = tempfile::tempdir().unwrap();
let profiles_dir = tmp.path().join("profiles");
let pm = ProfileManager::new(&profiles_dir, "test");
let sp1 = fake_store_path(tmp.path(), "path-a");
let sp2 = fake_store_path(tmp.path(), "path-b");
pm.set(&sp1).unwrap();
pm.set(&sp2).unwrap();
let generations = pm.list_generations().unwrap();
assert_eq!(generations[0].path, sp1);
assert_eq!(generations[1].path, sp2);
}
#[test]
fn parse_gen_number_valid() {
assert_eq!(parse_generation_number("system-42-link", "system").unwrap(), 42);
assert_eq!(parse_generation_number("default-1-link", "default").unwrap(), 1);
}
#[test]
fn parse_gen_number_invalid_format() {
assert!(parse_generation_number("system-abc-link", "system").is_err());
assert!(parse_generation_number("other-42-link", "system").is_err());
assert!(parse_generation_number("system-42", "system").is_err());
assert!(parse_generation_number("system--link", "system").is_err());
}
#[test]
fn system_profile_has_expected_paths() {
let pm = ProfileManager::system();
assert_eq!(
pm.profile_path(),
PathBuf::from("/nix/var/nix/profiles/system")
);
}
}