1use std::path::{Path, PathBuf};
10
11#[derive(Debug, thiserror::Error)]
15pub enum ProfileError {
16 #[error("I/O error: {0}")]
17 Io(#[from] std::io::Error),
18 #[error("invalid profile symlink")]
19 InvalidProfile,
20 #[error("generation {0} not found")]
21 GenerationNotFound(u32),
22 #[error("no current generation")]
23 NoCurrentGeneration,
24 #[error("no previous generation to rollback to")]
25 NoPreviousGeneration,
26}
27
28#[derive(Debug, Clone)]
32pub struct Generation {
33 pub number: u32,
35 pub path: PathBuf,
37 pub created: Option<std::time::SystemTime>,
39 pub current: bool,
41}
42
43pub struct ProfileManager {
49 profile_dir: PathBuf,
50 profile_name: String,
51}
52
53impl ProfileManager {
54 pub fn new(profile_dir: impl Into<PathBuf>, name: impl Into<String>) -> Self {
60 Self {
61 profile_dir: profile_dir.into(),
62 profile_name: name.into(),
63 }
64 }
65
66 #[must_use]
68 pub fn system() -> Self {
69 Self::new("/nix/var/nix/profiles", "system")
70 }
71
72 #[must_use]
74 pub fn profile_path(&self) -> PathBuf {
75 self.profile_dir.join(&self.profile_name)
76 }
77
78 fn generation_link(&self, gen_num: u32) -> PathBuf {
80 self.profile_dir
81 .join(format!("{}-{}-link", self.profile_name, gen_num))
82 }
83
84 pub fn current_generation(&self) -> Result<Option<u32>, ProfileError> {
88 let profile = self.profile_path();
89 if !profile.exists() {
90 return Ok(None);
91 }
92 let target = std::fs::read_link(&profile)?;
93 let filename = target
95 .file_name()
96 .and_then(|f| f.to_str())
97 .ok_or(ProfileError::InvalidProfile)?;
98 let number = parse_generation_number(filename, &self.profile_name)?;
99 Ok(Some(number))
100 }
101
102 pub fn list_generations(&self) -> Result<Vec<Generation>, ProfileError> {
104 if !self.profile_dir.exists() {
105 return Ok(Vec::new());
106 }
107
108 let mut generations = Vec::new();
109 let current = self.current_generation()?;
110
111 let prefix = format!("{}-", self.profile_name);
112 let suffix = "-link";
113
114 for entry in std::fs::read_dir(&self.profile_dir)? {
115 let entry = entry?;
116 let name = entry.file_name();
117 let name_str = name.to_string_lossy();
118
119 if !name_str.starts_with(&prefix) || !name_str.ends_with(suffix) {
120 continue;
121 }
122
123 if name_str.ends_with("-tmp-link") {
125 continue;
126 }
127
128 let mid = &name_str[prefix.len()..name_str.len() - suffix.len()];
129 if let Ok(num) = mid.parse::<u32>() {
130 let path = std::fs::read_link(entry.path())?;
131 let created = entry.metadata().ok().and_then(|m| m.created().ok());
132 generations.push(Generation {
133 number: num,
134 path,
135 created,
136 current: current == Some(num),
137 });
138 }
139 }
140
141 generations.sort_by_key(|g| g.number);
142 Ok(generations)
143 }
144
145 pub fn set(&self, store_path: &Path) -> Result<u32, ProfileError> {
149 std::fs::create_dir_all(&self.profile_dir)?;
150
151 let next = self.next_generation_number()?;
152 let link = self.generation_link(next);
153
154 std::os::unix::fs::symlink(store_path, &link)?;
156
157 self.atomic_switch(&link)?;
159
160 Ok(next)
161 }
162
163 pub fn switch_generation(&self, gen_num: u32) -> Result<(), ProfileError> {
165 let link = self.generation_link(gen_num);
166 if !link.exists() {
167 return Err(ProfileError::GenerationNotFound(gen_num));
168 }
169
170 self.atomic_switch(&link)?;
171 Ok(())
172 }
173
174 pub fn rollback(&self) -> Result<u32, ProfileError> {
178 let current = self
179 .current_generation()?
180 .ok_or(ProfileError::NoCurrentGeneration)?;
181
182 let generations = self.list_generations()?;
184 let prev = generations
185 .iter()
186 .filter(|g| g.number < current)
187 .max_by_key(|g| g.number)
188 .ok_or(ProfileError::NoPreviousGeneration)?;
189
190 self.switch_generation(prev.number)?;
191 Ok(prev.number)
192 }
193
194 pub fn delete_generation(&self, gen_num: u32) -> Result<(), ProfileError> {
198 let current = self.current_generation()?;
199 if current == Some(gen_num) {
200 return Err(ProfileError::Io(std::io::Error::new(
201 std::io::ErrorKind::PermissionDenied,
202 "cannot delete the current generation",
203 )));
204 }
205
206 let link = self.generation_link(gen_num);
207 if !link.exists() {
208 return Err(ProfileError::GenerationNotFound(gen_num));
209 }
210
211 std::fs::remove_file(&link)?;
212 Ok(())
213 }
214
215 fn atomic_switch(&self, target: &Path) -> Result<(), ProfileError> {
219 let profile = self.profile_path();
220 let tmp = self
221 .profile_dir
222 .join(format!("{}-tmp-link", self.profile_name));
223
224 let _ = std::fs::remove_file(&tmp);
226
227 std::os::unix::fs::symlink(target, &tmp)?;
228 std::fs::rename(&tmp, &profile)?; Ok(())
230 }
231
232 fn next_generation_number(&self) -> Result<u32, ProfileError> {
233 let generations = self.list_generations()?;
234 Ok(generations.last().map(|g| g.number + 1).unwrap_or(1))
235 }
236}
237
238fn parse_generation_number(filename: &str, profile_name: &str) -> Result<u32, ProfileError> {
240 let prefix = format!("{profile_name}-");
241 let suffix = "-link";
242 if !filename.starts_with(&prefix) || !filename.ends_with(suffix) {
243 return Err(ProfileError::InvalidProfile);
244 }
245 let mid = &filename[prefix.len()..filename.len() - suffix.len()];
246 mid.parse().map_err(|_| ProfileError::InvalidProfile)
247}
248
249#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn fake_store_path(tmp: &Path, name: &str) -> PathBuf {
257 let store = tmp.join("store");
258 let p = store.join(name);
259 std::fs::create_dir_all(&p).unwrap();
260 p
261 }
262
263 #[test]
266 fn set_creates_generation_and_profile_symlink() {
267 let tmp = tempfile::tempdir().unwrap();
268 let profiles_dir = tmp.path().join("profiles");
269 let pm = ProfileManager::new(&profiles_dir, "system");
270
271 let store_path = fake_store_path(tmp.path(), "abc123-foo");
272 let gen_num = pm.set(&store_path).unwrap();
273 assert_eq!(gen_num, 1);
274
275 let profile = pm.profile_path();
277 assert!(profile.is_symlink());
278
279 let gen_link = pm.generation_link(1);
281 assert!(gen_link.is_symlink());
282 let gen_target = std::fs::read_link(&gen_link).unwrap();
283 assert_eq!(gen_target, store_path);
284 }
285
286 #[test]
289 fn current_generation_returns_correct_number() {
290 let tmp = tempfile::tempdir().unwrap();
291 let profiles_dir = tmp.path().join("profiles");
292 let pm = ProfileManager::new(&profiles_dir, "system");
293
294 let sp1 = fake_store_path(tmp.path(), "gen1-path");
295 let sp2 = fake_store_path(tmp.path(), "gen2-path");
296
297 pm.set(&sp1).unwrap();
298 assert_eq!(pm.current_generation().unwrap(), Some(1));
299
300 pm.set(&sp2).unwrap();
301 assert_eq!(pm.current_generation().unwrap(), Some(2));
302 }
303
304 #[test]
307 fn list_generations_returns_all_sorted() {
308 let tmp = tempfile::tempdir().unwrap();
309 let profiles_dir = tmp.path().join("profiles");
310 let pm = ProfileManager::new(&profiles_dir, "test");
311
312 let sp1 = fake_store_path(tmp.path(), "g1");
313 let sp2 = fake_store_path(tmp.path(), "g2");
314 let sp3 = fake_store_path(tmp.path(), "g3");
315
316 pm.set(&sp1).unwrap();
317 pm.set(&sp2).unwrap();
318 pm.set(&sp3).unwrap();
319
320 let generations = pm.list_generations().unwrap();
321 assert_eq!(generations.len(), 3);
322 assert_eq!(generations[0].number, 1);
323 assert_eq!(generations[1].number, 2);
324 assert_eq!(generations[2].number, 3);
325
326 assert!(!generations[0].current);
328 assert!(!generations[1].current);
329 assert!(generations[2].current);
330 }
331
332 #[test]
335 fn switch_generation_updates_profile() {
336 let tmp = tempfile::tempdir().unwrap();
337 let profiles_dir = tmp.path().join("profiles");
338 let pm = ProfileManager::new(&profiles_dir, "myprofile");
339
340 let sp1 = fake_store_path(tmp.path(), "first");
341 let sp2 = fake_store_path(tmp.path(), "second");
342
343 pm.set(&sp1).unwrap();
344 pm.set(&sp2).unwrap();
345 assert_eq!(pm.current_generation().unwrap(), Some(2));
346
347 pm.switch_generation(1).unwrap();
348 assert_eq!(pm.current_generation().unwrap(), Some(1));
349 }
350
351 #[test]
354 fn rollback_switches_to_previous() {
355 let tmp = tempfile::tempdir().unwrap();
356 let profiles_dir = tmp.path().join("profiles");
357 let pm = ProfileManager::new(&profiles_dir, "sys");
358
359 let sp1 = fake_store_path(tmp.path(), "a");
360 let sp2 = fake_store_path(tmp.path(), "b");
361 let sp3 = fake_store_path(tmp.path(), "c");
362
363 pm.set(&sp1).unwrap();
364 pm.set(&sp2).unwrap();
365 pm.set(&sp3).unwrap();
366 assert_eq!(pm.current_generation().unwrap(), Some(3));
367
368 let prev = pm.rollback().unwrap();
369 assert_eq!(prev, 2);
370 assert_eq!(pm.current_generation().unwrap(), Some(2));
371 }
372
373 #[test]
376 fn multiple_sets_increment_generations() {
377 let tmp = tempfile::tempdir().unwrap();
378 let profiles_dir = tmp.path().join("profiles");
379 let pm = ProfileManager::new(&profiles_dir, "default");
380
381 for i in 1..=5 {
382 let sp = fake_store_path(tmp.path(), &format!("generation-{i}"));
383 let number = pm.set(&sp).unwrap();
384 assert_eq!(number, i);
385 }
386 }
387
388 #[test]
391 fn highest_generation_not_current_after_switch() {
392 let tmp = tempfile::tempdir().unwrap();
393 let profiles_dir = tmp.path().join("profiles");
394 let pm = ProfileManager::new(&profiles_dir, "test");
395
396 let sp1 = fake_store_path(tmp.path(), "x1");
397 let sp2 = fake_store_path(tmp.path(), "x2");
398 let sp3 = fake_store_path(tmp.path(), "x3");
399
400 pm.set(&sp1).unwrap();
401 pm.set(&sp2).unwrap();
402 pm.set(&sp3).unwrap();
403
404 pm.switch_generation(1).unwrap();
406
407 let generations = pm.list_generations().unwrap();
408 assert_eq!(generations.len(), 3);
409 assert!(generations[0].current); assert!(!generations[1].current);
411 assert!(!generations[2].current); }
413
414 #[test]
417 fn empty_profile_dir_returns_none_and_empty_list() {
418 let tmp = tempfile::tempdir().unwrap();
419 let profiles_dir = tmp.path().join("empty-profiles");
420 let pm = ProfileManager::new(&profiles_dir, "system");
422
423 assert_eq!(pm.current_generation().unwrap(), None);
424 assert!(pm.list_generations().unwrap().is_empty());
425 }
426
427 #[test]
430 fn set_does_not_leave_tmp_symlink() {
431 let tmp = tempfile::tempdir().unwrap();
432 let profiles_dir = tmp.path().join("profiles");
433 let pm = ProfileManager::new(&profiles_dir, "atomic");
434
435 let sp = fake_store_path(tmp.path(), "store-path");
436 pm.set(&sp).unwrap();
437
438 let tmp_link = profiles_dir.join("atomic-tmp-link");
440 assert!(!tmp_link.exists());
441 }
442
443 #[test]
446 fn rollback_from_first_generation_errors() {
447 let tmp = tempfile::tempdir().unwrap();
448 let profiles_dir = tmp.path().join("profiles");
449 let pm = ProfileManager::new(&profiles_dir, "sys");
450
451 let sp = fake_store_path(tmp.path(), "only");
452 pm.set(&sp).unwrap();
453
454 let result = pm.rollback();
455 assert!(result.is_err());
456 assert!(matches!(
457 result.unwrap_err(),
458 ProfileError::NoPreviousGeneration
459 ));
460 }
461
462 #[test]
465 fn rollback_with_no_profile_errors() {
466 let tmp = tempfile::tempdir().unwrap();
467 let profiles_dir = tmp.path().join("profiles");
468 let pm = ProfileManager::new(&profiles_dir, "sys");
469
470 let result = pm.rollback();
471 assert!(matches!(
472 result.unwrap_err(),
473 ProfileError::NoCurrentGeneration
474 ));
475 }
476
477 #[test]
480 fn switch_to_nonexistent_generation_errors() {
481 let tmp = tempfile::tempdir().unwrap();
482 let profiles_dir = tmp.path().join("profiles");
483 std::fs::create_dir_all(&profiles_dir).unwrap();
484 let pm = ProfileManager::new(&profiles_dir, "test");
485
486 let result = pm.switch_generation(99);
487 assert!(matches!(
488 result.unwrap_err(),
489 ProfileError::GenerationNotFound(99)
490 ));
491 }
492
493 #[test]
496 fn delete_generation_removes_link() {
497 let tmp = tempfile::tempdir().unwrap();
498 let profiles_dir = tmp.path().join("profiles");
499 let pm = ProfileManager::new(&profiles_dir, "test");
500
501 let sp1 = fake_store_path(tmp.path(), "d1");
502 let sp2 = fake_store_path(tmp.path(), "d2");
503
504 pm.set(&sp1).unwrap();
505 pm.set(&sp2).unwrap();
506
507 pm.delete_generation(1).unwrap();
508 let generations = pm.list_generations().unwrap();
509 assert_eq!(generations.len(), 1);
510 assert_eq!(generations[0].number, 2);
511 }
512
513 #[test]
516 fn delete_current_generation_errors() {
517 let tmp = tempfile::tempdir().unwrap();
518 let profiles_dir = tmp.path().join("profiles");
519 let pm = ProfileManager::new(&profiles_dir, "test");
520
521 let sp = fake_store_path(tmp.path(), "current");
522 pm.set(&sp).unwrap();
523
524 let result = pm.delete_generation(1);
525 assert!(result.is_err());
526 }
527
528 #[test]
531 fn generation_paths_are_correct() {
532 let tmp = tempfile::tempdir().unwrap();
533 let profiles_dir = tmp.path().join("profiles");
534 let pm = ProfileManager::new(&profiles_dir, "test");
535
536 let sp1 = fake_store_path(tmp.path(), "path-a");
537 let sp2 = fake_store_path(tmp.path(), "path-b");
538
539 pm.set(&sp1).unwrap();
540 pm.set(&sp2).unwrap();
541
542 let generations = pm.list_generations().unwrap();
543 assert_eq!(generations[0].path, sp1);
544 assert_eq!(generations[1].path, sp2);
545 }
546
547 #[test]
550 fn parse_gen_number_valid() {
551 assert_eq!(parse_generation_number("system-42-link", "system").unwrap(), 42);
552 assert_eq!(parse_generation_number("default-1-link", "default").unwrap(), 1);
553 }
554
555 #[test]
556 fn parse_gen_number_invalid_format() {
557 assert!(parse_generation_number("system-abc-link", "system").is_err());
558 assert!(parse_generation_number("other-42-link", "system").is_err());
559 assert!(parse_generation_number("system-42", "system").is_err());
560 assert!(parse_generation_number("system--link", "system").is_err());
561 }
562
563 #[test]
566 fn system_profile_has_expected_paths() {
567 let pm = ProfileManager::system();
568 assert_eq!(
569 pm.profile_path(),
570 PathBuf::from("/nix/var/nix/profiles/system")
571 );
572 }
573}