1use std::collections::BTreeMap;
50use std::fs;
51use std::path::{Path, PathBuf};
52
53use anyhow::{Context, Result, bail};
54use serde::{Deserialize, Serialize};
55
56use crate::sysroot;
57use crate::types::AlternativeEntry;
58
59const STATE_DIR: &str = "var/lib/zoi/alternatives";
61
62const ETC_ALTERNATIVES_DIR: &str = "etc/alternatives";
67
68const STATE_FILE: &str = "state.json";
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct Alternative {
74 pub path: String,
76 pub priority: i32,
78 pub package: String,
80 pub sequence: u64
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct AlternativeGroup {
90 pub name: String,
92 pub link: String,
94 pub alternatives: Vec<Alternative>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub link_set: Option<String>
99}
100
101impl AlternativeGroup {
102 pub fn current(&self) -> Option<&Alternative> {
108 if let Some(chosen) = &self.link_set
109 && let Some(found) =
110 self.alternatives.iter().find(|a| &a.path == chosen)
111 {
112 return Some(found);
113 }
114
115 self.best_by_priority()
116 }
117
118 fn best_by_priority(&self) -> Option<&Alternative> {
121 self.alternatives
122 .iter()
123 .max_by(|a, b| {
126 (a.priority, a.sequence).cmp(&(b.priority, b.sequence))
127 })
128 }
129
130 pub fn status(&self) -> AlternativeStatus {
132 let current = self.current();
133 let mut manual = false;
134
135 if let Some(chosen) = &self.link_set
136 && self.alternatives.iter().any(|a| &a.path == chosen)
137 {
138 manual = true;
139 }
140
141 AlternativeStatus {
142 name: self.name.clone(),
143 link: self.link.clone(),
144 current: current.map(|a| a.path.clone()),
145 manual,
146 alternatives: self.alternatives.clone()
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct AlternativeStatus {
154 pub name: String,
156 pub link: String,
158 pub current: Option<String>,
160 pub manual: bool,
162 pub alternatives: Vec<Alternative>
164}
165
166fn root() -> PathBuf {
168 sysroot::get_sysroot().unwrap_or_else(|| PathBuf::from("/"))
169}
170
171fn in_root(root: &Path, absolute: &str) -> PathBuf {
173 root.join(absolute.trim_start_matches('/'))
174}
175
176fn validate_absolute(field: &str, value: &str) -> Result<()> {
182 if !value.starts_with('/') {
183 bail!("{field} must be an absolute path, got '{value}'");
184 }
185 if value.contains("..") {
186 bail!("{field} must not contain '..', got '{value}'");
187 }
188 Ok(())
189}
190
191fn validate_name(name: &str) -> Result<()> {
193 if name.is_empty() {
194 bail!("An alternative name must not be empty");
195 }
196 if name.contains('/') || name.contains('\0') {
197 bail!("Invalid alternative name '{name}': must not contain '/'");
198 }
199 Ok(())
200}
201
202pub fn list() -> Result<Vec<AlternativeGroup>> {
212 list_in(&root())
213}
214
215pub fn list_in(root: &Path) -> Result<Vec<AlternativeGroup>> {
223 let dir = root.join(STATE_DIR);
224 let Ok(entries) = fs::read_dir(&dir) else {
225 return Ok(Vec::new());
227 };
228
229 let mut groups = BTreeMap::new();
230
231 for entry in entries.flatten() {
232 let path = entry.path();
233 if !path.is_dir() {
234 continue;
235 }
236 let Ok(group) = read_group(&path) else {
237 continue;
241 };
242 groups.insert(group.name.clone(), group);
243 }
244
245 Ok(groups.into_values().collect())
246}
247
248pub fn get(name: &str) -> Result<Option<AlternativeGroup>> {
255 get_in(&root(), name)
256}
257
258pub fn get_in(root: &Path, name: &str) -> Result<Option<AlternativeGroup>> {
265 validate_name(name)?;
266 let path = root.join(STATE_DIR).join(name);
267 if !path.is_dir() {
268 return Ok(None);
269 }
270 Ok(Some(read_group(&path)?))
271}
272
273fn read_group(dir: &Path) -> Result<AlternativeGroup> {
275 let path = dir.join(STATE_FILE);
276 let content = fs::read_to_string(&path)
277 .with_context(|| format!("Failed to read {}", path.display()))?;
278 serde_json::from_str(&content)
279 .with_context(|| format!("Failed to parse {}", path.display()))
280}
281
282fn write_group(root: &Path, group: &AlternativeGroup) -> Result<()> {
287 let dir = root.join(STATE_DIR).join(&group.name);
288 fs::create_dir_all(&dir)
289 .with_context(|| format!("Failed to create {}", dir.display()))?;
290
291 let final_path = dir.join(STATE_FILE);
292 let temp_path = dir.join(format!("{STATE_FILE}.tmp"));
293
294 fs::write(&temp_path, serde_json::to_string_pretty(group)?)
295 .with_context(|| format!("Failed to write {}", temp_path.display()))?;
296 fs::rename(&temp_path, &final_path).with_context(|| {
297 format!("Failed to replace {}", final_path.display())
298 })?;
299
300 Ok(())
301}
302
303fn next_sequence(group: &AlternativeGroup) -> u64 {
308 group
309 .alternatives
310 .iter()
311 .map(|a| a.sequence)
312 .max()
313 .unwrap_or(0)
314 + 1
315}
316
317pub fn register(
330 entries: &[AlternativeEntry],
331 package: &str
332) -> Result<Vec<String>> {
333 register_in(&root(), entries, package)
334}
335
336pub fn register_in(
342 root: &Path,
343 entries: &[AlternativeEntry],
344 package: &str
345) -> Result<Vec<String>> {
346 if entries.is_empty() {
347 return Ok(Vec::new());
348 }
349
350 let mut changed = Vec::new();
351
352 let mut grouped: BTreeMap<&str, Vec<&AlternativeEntry>> = BTreeMap::new();
355 for entry in entries {
356 validate_name(&entry.name)?;
357 validate_absolute("alternatives.path", &entry.path)?;
358 validate_absolute("alternatives.link", &entry.link)?;
359 grouped.entry(&entry.name).or_default().push(entry);
360 }
361
362 for (name, group_entries) in grouped {
363 let Some(first_entry) = group_entries.first() else {
367 continue;
368 };
369
370 let mut group = get_in(root, name)?.unwrap_or(AlternativeGroup {
371 name: name.to_string(),
372 link: first_entry.link.clone(),
373 alternatives: Vec::new(),
374 link_set: None
375 });
376
377 if group.link != first_entry.link {
383 eprintln!(
384 "Warning: alternative '{name}' is registered with link '{}' \
385 but '{}' expects '{}'. Keeping '{}'.",
386 group.link, package, first_entry.link, group.link
387 );
388 }
389
390 let before = group.current().map(|a| a.path.clone());
391
392 for entry in group_entries {
393 let sequence = next_sequence(&group);
394
395 match group.alternatives.iter_mut().find(|a| a.path == entry.path) {
396 Some(existing) => {
397 existing.priority = entry.priority;
398 existing.package = package.to_string();
399 existing.sequence = sequence;
402 }
403 None => group.alternatives.push(Alternative {
404 path: entry.path.clone(),
405 priority: entry.priority,
406 package: package.to_string(),
407 sequence
408 })
409 }
410 }
411
412 let after = group.current().map(|a| a.path.clone());
413 write_group(root, &group)?;
414 apply_links(root, &group)?;
415
416 if before != after {
417 changed.push(name.to_string());
418 }
419 }
420
421 Ok(changed)
422}
423
424pub fn deregister(package: &str) -> Result<Vec<String>> {
434 deregister_in(&root(), package)
435}
436
437pub fn deregister_in(root: &Path, package: &str) -> Result<Vec<String>> {
443 let groups = list_in(root)?;
444 let mut changed = Vec::new();
445
446 for mut group in groups {
447 if !group.alternatives.iter().any(|a| a.package == package) {
448 continue;
449 }
450
451 let before = group.current().map(|a| a.path.clone());
452 group.alternatives.retain(|a| a.package != package);
453
454 if group.link_set.as_ref().is_some_and(|chosen| {
457 group.alternatives.iter().all(|a| &a.path != chosen)
458 }) {
459 group.link_set = None;
460 }
461
462 let after = group.current().map(|a| a.path.clone());
463
464 if group.alternatives.is_empty() {
465 remove_group(root, &group)?;
469 } else {
470 write_group(root, &group)?;
471 apply_links(root, &group)?;
472 }
473
474 if before != after {
475 changed.push(group.name);
476 }
477 }
478
479 Ok(changed)
480}
481
482fn remove_group(root: &Path, group: &AlternativeGroup) -> Result<()> {
484 let _ = fs::remove_file(in_root(root, &group.link));
485 let _ = fs::remove_file(in_root(root, &etc_path(&group.name)?));
486 let _ = fs::remove_dir_all(root.join(STATE_DIR).join(&group.name));
487 Ok(())
488}
489
490pub fn set(name: &str, path: &str) -> Result<()> {
499 set_in(&root(), name, path)
500}
501
502pub fn set_in(root: &Path, name: &str, path: &str) -> Result<()> {
508 let Some(mut group) = get_in(root, name)? else {
509 bail!("No such alternative: '{name}'. Run 'zoi alt list' to see them.");
510 };
511
512 if !group.alternatives.iter().any(|a| a.path == path) {
513 bail!(
514 "'{path}' is not a registered alternative for '{name}'. \
515 Available: {}",
516 group
517 .alternatives
518 .iter()
519 .map(|a| a.path.clone())
520 .collect::<Vec<_>>()
521 .join(", ")
522 );
523 }
524
525 group.link_set = Some(path.to_string());
526 write_group(root, &group)?;
527 apply_links(root, &group)?;
528
529 Ok(())
530}
531
532pub fn auto(name: &str) -> Result<Option<String>> {
539 auto_in(&root(), name)
540}
541
542pub fn auto_in(root: &Path, name: &str) -> Result<Option<String>> {
548 let Some(mut group) = get_in(root, name)? else {
549 bail!("No such alternative: '{name}'.");
550 };
551
552 group.link_set = None;
553 let selected = group.current().map(|a| a.path.clone());
554
555 write_group(root, &group)?;
556 apply_links(root, &group)?;
557
558 Ok(selected)
559}
560
561pub fn remove_implementation(name: &str, path: &str) -> Result<()> {
571 remove_implementation_in(&root(), name, path)
572}
573
574pub fn remove_implementation_in(
580 root: &Path,
581 name: &str,
582 path: &str
583) -> Result<()> {
584 let Some(mut group) = get_in(root, name)? else {
585 bail!("No such alternative: '{name}'.");
586 };
587
588 let before = group.alternatives.len();
589 group.alternatives.retain(|a| a.path != path);
590
591 if group.alternatives.len() == before {
592 bail!("'{path}' is not registered as an alternative for '{name}'.");
593 }
594
595 if group.link_set.as_deref() == Some(path) {
596 group.link_set = None;
597 }
598
599 if group.alternatives.is_empty() {
600 remove_group(root, &group)?;
601 } else {
602 write_group(root, &group)?;
603 apply_links(root, &group)?;
604 }
605
606 Ok(())
607}
608
609fn etc_path(name: &str) -> Result<String> {
611 validate_name(name)?;
612 Ok(format!("/{ETC_ALTERNATIVES_DIR}/{name}"))
613}
614
615fn apply_links(root: &Path, group: &AlternativeGroup) -> Result<()> {
623 let etc_dir = root.join(ETC_ALTERNATIVES_DIR);
624 fs::create_dir_all(&etc_dir)
625 .with_context(|| format!("Failed to create {}", etc_dir.display()))?;
626
627 let etc_link = etc_dir.join(&group.name);
628
629 if let Some(selected) = group.current() {
630 symlink_to(root, &selected.path, &etc_link)?;
631
632 let canonical = in_root(root, &group.link);
635 symlink_to(root, &etc_path(&group.name)?, &canonical)?;
636 } else {
637 let _ = fs::remove_file(&etc_link);
641 let _ = fs::remove_file(in_root(root, &group.link));
642 }
643
644 Ok(())
645}
646
647fn symlink_to(root: &Path, target: &str, link: &Path) -> Result<()> {
653 let effective = if root == Path::new("/") {
657 target.to_string()
658 } else {
659 in_root(root, target).to_string_lossy().to_string()
660 };
661
662 if let Ok(existing) = fs::read_link(link)
663 && existing.to_string_lossy() == effective
664 {
665 return Ok(());
666 }
667
668 if let Some(parent) = link.parent() {
669 fs::create_dir_all(parent).with_context(|| {
670 format!("Failed to create {}", parent.display())
671 })?;
672 }
673
674 match fs::symlink_metadata(link) {
678 Ok(meta) if meta.is_dir() => {
679 fs::remove_dir_all(link).with_context(|| {
680 format!("Failed to replace directory {}", link.display())
681 })?;
682 }
683 Ok(_) => {
684 fs::remove_file(link).with_context(|| {
685 format!("Failed to remove {}", link.display())
686 })?;
687 }
688 Err(_) => {}
689 }
690
691 #[cfg(unix)]
698 let created = std::os::unix::fs::symlink(&effective, link);
699 #[cfg(windows)]
700 let created = if Path::new(&effective).is_dir() {
701 std::os::windows::fs::symlink_dir(&effective, link)
702 } else {
703 std::os::windows::fs::symlink_file(&effective, link)
704 };
705
706 created.with_context(|| {
707 format!("Failed to create symlink {} -> {effective}", link.display())
708 })?;
709
710 Ok(())
711}
712
713pub fn status() -> Result<Vec<AlternativeStatus>> {
719 Ok(list()?.iter().map(AlternativeGroup::status).collect())
720}
721
722pub fn reconcile(touched: &[String]) -> Result<Vec<String>> {
731 reconcile_in(&root(), touched)
732}
733
734pub fn reconcile_in(root: &Path, touched: &[String]) -> Result<Vec<String>> {
740 if touched.is_empty() {
741 return Ok(Vec::new());
742 }
743
744 let mut names: Vec<String> = touched.to_vec();
747 names.sort();
748 names.dedup();
749
750 let mut changed = Vec::new();
751
752 for name in &names {
753 let manifest = find_installed(root, name);
754
755 match manifest {
756 Some(manifest) => {
757 let entries = manifest.alternatives.unwrap_or_default();
758
759 let stale = stale_groups(root, name, &entries)?;
764 for group in stale {
765 if deregister_group(root, &group, name)? {
766 changed.push(group);
767 }
768 }
769
770 if entries.is_empty() {
771 continue;
772 }
773
774 changed.extend(register_in(root, &entries, name)?);
775 }
776 None => {
777 changed.extend(deregister_in(root, name)?);
778 }
779 }
780 }
781
782 changed.sort();
783 changed.dedup();
784 Ok(changed)
785}
786
787fn store_dirs(root: &Path) -> Vec<PathBuf> {
799 let mut dirs = Vec::new();
800
801 dirs.push(join_under(root, Path::new("/var/lib/zoi/pkgs/store")));
804
805 if root == Path::new("/") {
806 if let Ok(user_store) =
809 crate::utils::get_store_base_dir(crate::types::Scope::User)
810 {
811 dirs.push(user_store);
812 }
813 } else {
814 dirs.push(root.join(".local/share/zoi/pkgs/store"));
818 }
819
820 dirs
821}
822
823fn join_under(root: &Path, absolute: &Path) -> PathBuf {
825 if root == Path::new("/") {
826 return absolute.to_path_buf();
827 }
828 root.join(absolute.strip_prefix("/").unwrap_or(absolute))
829}
830
831fn find_installed(
841 root: &Path,
842 name: &str
843) -> Option<crate::types::InstallManifest> {
844 let suffix = format!("-{name}");
845
846 for store in store_dirs(root) {
847 let Ok(entries) = fs::read_dir(&store) else {
848 continue;
849 };
850
851 for entry in entries.flatten() {
852 let pkg_dir = entry.path();
853 if !pkg_dir.is_dir() {
854 continue;
855 }
856
857 let dir_name = pkg_dir.file_name().and_then(|n| n.to_str());
858 let Some(dir_name) = dir_name else {
859 continue;
860 };
861 if !dir_name.ends_with(&suffix) {
862 continue;
863 }
864
865 let Ok(versions) = fs::read_dir(&pkg_dir) else {
866 continue;
867 };
868
869 for version in versions.flatten() {
870 let manifest_path = version.path().join("manifest.json");
871 let Ok(content) = fs::read_to_string(&manifest_path) else {
872 continue;
873 };
874 if let Ok(manifest) = serde_json::from_str::<
875 crate::types::InstallManifest
876 >(&content)
877 {
878 return Some(manifest);
879 }
880 }
881 }
882 }
883
884 None
885}
886
887fn stale_groups(
892 root: &Path,
893 package: &str,
894 declared: &[AlternativeEntry]
895) -> Result<Vec<String>> {
896 let groups = list_in(root)?;
897
898 let mut stale = Vec::new();
899
900 for group in groups {
901 let owned_paths: Vec<&str> = group
903 .alternatives
904 .iter()
905 .filter(|a| a.package == package)
906 .map(|a| a.path.as_str())
907 .collect();
908
909 if owned_paths.is_empty() {
910 continue;
911 }
912
913 let no_longer_declared = owned_paths.iter().any(|path| {
915 !declared
916 .iter()
917 .any(|entry| entry.name == group.name && entry.path == **path)
918 });
919
920 if no_longer_declared {
921 stale.push(group.name);
922 }
923 }
924
925 Ok(stale)
926}
927
928fn deregister_group(root: &Path, name: &str, package: &str) -> Result<bool> {
932 let Some(mut group) = get_in(root, name)? else {
933 return Ok(false);
934 };
935
936 let before = group.current().map(|a| a.path.clone());
937 group.alternatives.retain(|a| a.package != package);
938
939 if group.link_set.as_ref().is_some_and(|chosen| {
940 group.alternatives.iter().all(|a| &a.path != chosen)
941 }) {
942 group.link_set = None;
943 }
944
945 let after = group.current().map(|a| a.path.clone());
946
947 if group.alternatives.is_empty() {
948 remove_group(root, &group)?;
949 } else {
950 write_group(root, &group)?;
951 apply_links(root, &group)?;
952 }
953
954 Ok(before != after)
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 fn temp_root() -> tempfile::TempDir {
962 tempfile::tempdir().expect("temp dir")
963 }
964
965 fn entry(
966 name: &str,
967 path: &str,
968 link: &str,
969 priority: i32
970 ) -> AlternativeEntry {
971 AlternativeEntry {
972 name: name.to_string(),
973 path: path.to_string(),
974 link: link.to_string(),
975 priority
976 }
977 }
978
979 fn make_binary(root: &Path, path: &str) {
982 let full = in_root(root, path);
983 fs::create_dir_all(full.parent().expect("parent")).expect("mkdir");
984 fs::write(&full, "#!/bin/true\n").expect("write");
985 }
986
987 fn read_link(root: &Path, path: &str) -> String {
988 fs::read_link(in_root(root, path))
989 .expect("link")
990 .to_string_lossy()
991 .to_string()
992 }
993
994 fn current_path_in(root: &Path, name: &str) -> String {
1001 let group = get_in(root, name)
1002 .expect("group lookup succeeds")
1003 .unwrap_or_else(|| panic!("alternative '{name}' should exist"));
1004
1005 group
1006 .current()
1007 .unwrap_or_else(|| {
1008 panic!("alternative '{name}' should have a selection")
1009 })
1010 .path
1011 .clone()
1012 }
1013
1014 #[test]
1015 fn registration_links_the_canonical_path() {
1016 let root = temp_root();
1017 make_binary(root.path(), "/usr/bin/gawk");
1018
1019 register_in(
1020 root.path(),
1021 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1022 "gawk"
1023 )
1024 .expect("register");
1025
1026 assert_eq!(
1028 read_link(root.path(), "/usr/bin/awk"),
1029 format!("{}/etc/alternatives/awk", root.path().display())
1030 );
1031 assert!(
1032 read_link(root.path(), "/etc/alternatives/awk")
1033 .ends_with("/usr/bin/gawk")
1034 );
1035 }
1036
1037 #[test]
1038 fn highest_priority_wins() {
1039 let root = temp_root();
1040 let r = root.path().to_path_buf();
1041 make_binary(&r, "/usr/bin/mawk");
1042 make_binary(&r, "/usr/bin/gawk");
1043
1044 register_in(
1045 &r,
1046 &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1047 "mawk"
1048 )
1049 .expect("mawk");
1050 register_in(
1051 &r,
1052 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1053 "gawk"
1054 )
1055 .expect("gawk");
1056
1057 let group = get_in(&r, "awk").expect("get").expect("group");
1058 assert_eq!(
1059 group.current().map(|a| a.path.as_str()),
1060 Some("/usr/bin/gawk")
1061 );
1062 assert!(
1063 read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
1064 );
1065 }
1066
1067 #[test]
1068 fn equal_priority_breaks_tie_on_most_recent() {
1069 let root = temp_root();
1070 let r = root.path().to_path_buf();
1071 make_binary(&r, "/usr/bin/gawk");
1072 make_binary(&r, "/usr/bin/nawk");
1073
1074 register_in(
1075 &r,
1076 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1077 "gawk"
1078 )
1079 .expect("gawk");
1080 register_in(
1081 &r,
1082 &[entry("awk", "/usr/bin/nawk", "/usr/bin/awk", 100)],
1083 "nawk"
1084 )
1085 .expect("nawk");
1086
1087 let group = get_in(&r, "awk").expect("get").expect("group");
1090 assert_eq!(
1091 group.current().map(|a| a.path.as_str()),
1092 Some("/usr/bin/nawk")
1093 );
1094 }
1095
1096 #[test]
1097 fn manual_selection_beats_priority() {
1098 let root = temp_root();
1099 let r = root.path().to_path_buf();
1100 make_binary(&r, "/usr/bin/gawk");
1101 make_binary(&r, "/usr/bin/mawk");
1102
1103 register_in(
1104 &r,
1105 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1106 "gawk"
1107 )
1108 .expect("gawk");
1109 register_in(
1110 &r,
1111 &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1112 "mawk"
1113 )
1114 .expect("mawk");
1115
1116 set_in(&r, "awk", "/usr/bin/mawk").expect("set");
1117 assert!(
1118 read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
1119 );
1120
1121 let group = get_in(&r, "awk").expect("get").expect("group");
1122 assert!(group.status().manual, "status must report a manual choice");
1123 }
1124
1125 #[test]
1126 fn manual_selection_survives_a_higher_priority_install() {
1127 let root = temp_root();
1128 let r = root.path().to_path_buf();
1129 make_binary(&r, "/usr/bin/gawk");
1130 make_binary(&r, "/usr/bin/busybox");
1131
1132 register_in(
1133 &r,
1134 &[entry("vi", "/usr/bin/gawk", "/usr/bin/vi", 100)],
1135 "gawk"
1136 )
1137 .expect("gawk");
1138 set_in(&r, "vi", "/usr/bin/gawk").expect("set");
1139
1140 register_in(
1143 &r,
1144 &[entry("vi", "/usr/bin/busybox", "/usr/bin/vi", 500)],
1145 "busybox"
1146 )
1147 .expect("busybox");
1148
1149 let group = get_in(&r, "vi").expect("get").expect("group");
1150 assert_eq!(
1151 group.current().map(|a| a.path.as_str()),
1152 Some("/usr/bin/gawk"),
1153 "a manual choice must not be undone by an install"
1154 );
1155 }
1156
1157 #[test]
1158 fn auto_drops_the_override_and_returns_to_priority() {
1159 let root = temp_root();
1160 let r = root.path().to_path_buf();
1161 make_binary(&r, "/usr/bin/gawk");
1162 make_binary(&r, "/usr/bin/mawk");
1163
1164 register_in(
1165 &r,
1166 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1167 "gawk"
1168 )
1169 .expect("gawk");
1170 register_in(
1171 &r,
1172 &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1173 "mawk"
1174 )
1175 .expect("mawk");
1176 set_in(&r, "awk", "/usr/bin/mawk").expect("set");
1177
1178 let chosen = auto_in(&r, "awk").expect("auto");
1179 assert_eq!(chosen.as_deref(), Some("/usr/bin/gawk"));
1180 assert!(
1181 read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
1182 );
1183
1184 let group = get_in(&r, "awk").expect("get").expect("group");
1185 assert!(!group.status().manual);
1186 }
1187
1188 #[test]
1189 fn deregistering_the_active_implementation_falls_back() {
1190 let root = temp_root();
1191 let r = root.path().to_path_buf();
1192 make_binary(&r, "/usr/bin/gawk");
1193 make_binary(&r, "/usr/bin/mawk");
1194
1195 register_in(
1196 &r,
1197 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1198 "gawk"
1199 )
1200 .expect("gawk");
1201 register_in(
1202 &r,
1203 &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1204 "mawk"
1205 )
1206 .expect("mawk");
1207
1208 deregister_in(&r, "gawk").expect("deregister");
1209
1210 assert!(
1213 read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
1214 );
1215 assert!(
1216 read_link(&r, "/usr/bin/awk").ends_with("/etc/alternatives/awk")
1217 );
1218 }
1219
1220 #[test]
1221 fn deregistering_the_last_implementation_removes_the_links() {
1222 let root = temp_root();
1223 let r = root.path().to_path_buf();
1224 make_binary(&r, "/usr/bin/gawk");
1225
1226 register_in(
1227 &r,
1228 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1229 "gawk"
1230 )
1231 .expect("gawk");
1232 deregister_in(&r, "gawk").expect("deregister");
1233
1234 assert!(in_root(&r, "/usr/bin/awk").symlink_metadata().is_err());
1236 assert!(
1237 in_root(&r, "/etc/alternatives/awk")
1238 .symlink_metadata()
1239 .is_err()
1240 );
1241 assert!(get_in(&r, "awk").expect("get").is_none());
1242 }
1243
1244 #[test]
1245 fn deregistering_clears_a_manual_choice_of_the_removed_package() {
1246 let root = temp_root();
1247 let r = root.path().to_path_buf();
1248 make_binary(&r, "/usr/bin/gawk");
1249 make_binary(&r, "/usr/bin/mawk");
1250
1251 register_in(
1252 &r,
1253 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1254 "gawk"
1255 )
1256 .expect("gawk");
1257 register_in(
1258 &r,
1259 &[entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)],
1260 "mawk"
1261 )
1262 .expect("mawk");
1263 set_in(&r, "awk", "/usr/bin/mawk").expect("set");
1264
1265 deregister_in(&r, "mawk").expect("deregister");
1266
1267 let group = get_in(&r, "awk").expect("get").expect("group");
1268 assert_eq!(
1271 group.current().map(|a| a.path.as_str()),
1272 Some("/usr/bin/gawk")
1273 );
1274 assert!(!group.status().manual);
1275 }
1276
1277 #[test]
1278 fn deregistering_an_unrelated_package_changes_nothing() {
1279 let root = temp_root();
1280 let r = root.path().to_path_buf();
1281 make_binary(&r, "/usr/bin/gawk");
1282
1283 register_in(
1284 &r,
1285 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1286 "gawk"
1287 )
1288 .expect("gawk");
1289
1290 let changed = deregister_in(&r, "unrelated").expect("deregister");
1291 assert_eq!(changed.len(), 0, "changes: {changed:?}");
1292 assert!(
1293 read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/gawk")
1294 );
1295 }
1296
1297 #[test]
1298 fn registration_is_idempotent_per_path() {
1299 let root = temp_root();
1300 let r = root.path().to_path_buf();
1301 make_binary(&r, "/usr/bin/gawk");
1302
1303 let e = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
1304 register_in(&r, &e, "gawk").expect("first");
1305 register_in(&r, &e, "gawk").expect("second");
1306
1307 let group = get_in(&r, "awk").expect("get").expect("group");
1308 assert_eq!(group.alternatives.len(), 1);
1311 }
1312
1313 #[test]
1314 fn re_registration_refreshes_the_tie_break_position() {
1315 let root = temp_root();
1316 let r = root.path().to_path_buf();
1317 make_binary(&r, "/usr/bin/gawk");
1318 make_binary(&r, "/usr/bin/nawk");
1319
1320 let gawk = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
1321 register_in(&r, &gawk, "gawk").expect("gawk");
1322 register_in(
1323 &r,
1324 &[entry("awk", "/usr/bin/nawk", "/usr/bin/awk", 100)],
1325 "nawk"
1326 )
1327 .expect("nawk");
1328 let current = current_path_in(&r, "awk");
1329 assert_eq!(current, "/usr/bin/nawk");
1330
1331 register_in(&r, &gawk, "gawk").expect("reinstall");
1334 let current = current_path_in(&r, "awk");
1335 assert_eq!(current, "/usr/bin/gawk");
1336 }
1337
1338 #[test]
1339 fn multiple_groups_are_tracked_separately() {
1340 let root = temp_root();
1341 let r = root.path().to_path_buf();
1342 make_binary(&r, "/usr/bin/gawk");
1343 make_binary(&r, "/usr/bin/vim");
1344 make_binary(&r, "/usr/bin/nvim");
1345
1346 register_in(
1347 &r,
1348 &[
1349 entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
1350 entry("editor", "/usr/bin/vim", "/usr/bin/editor", 100),
1351 entry("vi", "/usr/bin/vim", "/usr/bin/vi", 100)
1352 ],
1353 "gvim"
1354 )
1355 .expect("register");
1356
1357 register_in(
1360 &r,
1361 &[entry("editor", "/usr/bin/nvim", "/usr/bin/editor", 50)],
1362 "neovim"
1363 )
1364 .expect("neovim");
1365
1366 let groups = list_in(&r).expect("list");
1367 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
1368 assert_eq!(names, vec!["awk", "editor", "vi"]);
1369 let second = groups.get(1).expect("two groups");
1370 assert_eq!(second.alternatives.len(), 2);
1371 }
1372
1373 #[test]
1374 fn relative_paths_are_rejected() {
1375 let root = temp_root();
1376 let r = root.path().to_path_buf();
1377
1378 let err = register_in(
1381 &r,
1382 &[entry("awk", "usr/bin/gawk", "/usr/bin/awk", 100)],
1383 "gawk"
1384 )
1385 .expect_err("must reject a relative path");
1386 assert!(err.to_string().contains("absolute"));
1387 }
1388
1389 #[test]
1390 fn traversal_in_paths_is_rejected() {
1391 let root = temp_root();
1392 let r = root.path().to_path_buf();
1393
1394 assert!(
1395 register_in(
1396 &r,
1397 &[entry(
1398 "awk",
1399 "/usr/bin/../../etc/shadow",
1400 "/usr/bin/awk",
1401 100
1402 )],
1403 "evil",
1404 )
1405 .is_err()
1406 );
1407 }
1408
1409 #[test]
1410 fn group_names_cannot_escape_the_state_directory() {
1411 let root = temp_root();
1412 let r = root.path().to_path_buf();
1413
1414 assert!(
1416 register_in(
1417 &r,
1418 &[entry("../../escape", "/usr/bin/x", "/usr/bin/x", 1)],
1419 "evil",
1420 )
1421 .is_err()
1422 );
1423 }
1424
1425 #[test]
1426 fn setting_an_unregistered_path_fails() {
1427 let root = temp_root();
1428 let r = root.path().to_path_buf();
1429 make_binary(&r, "/usr/bin/gawk");
1430 register_in(
1431 &r,
1432 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1433 "gawk"
1434 )
1435 .expect("register");
1436
1437 let err = set_in(&r, "awk", "/usr/bin/nope").expect_err("must reject");
1439 assert!(err.to_string().contains("not a registered alternative"));
1440 }
1441
1442 #[test]
1443 fn removing_an_implementation_falls_back() {
1444 let root = temp_root();
1445 let r = root.path().to_path_buf();
1446 make_binary(&r, "/usr/bin/gawk");
1447 make_binary(&r, "/usr/bin/mawk");
1448
1449 register_in(
1450 &r,
1451 &[
1452 entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
1453 entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)
1454 ],
1455 "combined"
1456 )
1457 .expect("register");
1458
1459 remove_implementation_in(&r, "awk", "/usr/bin/gawk").expect("remove");
1460 assert!(
1461 read_link(&r, "/etc/alternatives/awk").ends_with("/usr/bin/mawk")
1462 );
1463 }
1464
1465 #[test]
1466 fn listing_an_empty_root_is_not_an_error() {
1467 let root = temp_root();
1468 assert_eq!(list_in(root.path()).expect("list").len(), 0);
1469 assert!(get_in(root.path(), "nothing").expect("get").is_none());
1470 }
1471
1472 #[test]
1473 fn symlinks_are_not_rewritten_when_unchanged() {
1474 let root = temp_root();
1475 let r = root.path().to_path_buf();
1476 make_binary(&r, "/usr/bin/gawk");
1477 let e = [entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)];
1478
1479 register_in(&r, &e, "gawk").expect("first");
1480 let before = fs::symlink_metadata(in_root(&r, "/usr/bin/awk"))
1481 .expect("stat")
1482 .modified()
1483 .expect("mtime");
1484
1485 std::thread::sleep(std::time::Duration::from_millis(1100));
1486 register_in(&r, &e, "gawk").expect("second");
1487 let after = fs::symlink_metadata(in_root(&r, "/usr/bin/awk"))
1488 .expect("stat")
1489 .modified()
1490 .expect("mtime");
1491
1492 assert_eq!(before, after);
1495 }
1496
1497 #[test]
1498 fn registering_a_directory_in_the_way_is_handled() {
1499 let root = temp_root();
1500 let r = root.path().to_path_buf();
1501 make_binary(&r, "/usr/bin/gawk");
1502
1503 fs::create_dir_all(in_root(&r, "/usr/bin/awk")).expect("mkdir");
1505
1506 register_in(
1507 &r,
1508 &[entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100)],
1509 "gawk"
1510 )
1511 .expect("register");
1512
1513 assert!(
1514 in_root(&r, "/usr/bin/awk")
1515 .symlink_metadata()
1516 .expect("stat fixture path")
1517 .is_symlink()
1518 );
1519 }
1520
1521 #[test]
1522 fn status_reports_every_field_the_cli_needs() {
1523 let root = temp_root();
1524 let r = root.path().to_path_buf();
1525 make_binary(&r, "/usr/bin/gawk");
1526 make_binary(&r, "/usr/bin/mawk");
1527
1528 register_in(
1529 &r,
1530 &[
1531 entry("awk", "/usr/bin/gawk", "/usr/bin/awk", 100),
1532 entry("awk", "/usr/bin/mawk", "/usr/bin/awk", 50)
1533 ],
1534 "combined"
1535 )
1536 .expect("register");
1537
1538 let group = get_in(&r, "awk")
1539 .expect("group exists")
1540 .expect("group is present");
1541 let s = group.status();
1542 assert_eq!(s.name, "awk");
1543 assert_eq!(s.link, "/usr/bin/awk");
1544 assert_eq!(s.current.as_deref(), Some("/usr/bin/gawk"));
1545 assert!(!s.manual);
1546 assert_eq!(s.alternatives.len(), 2);
1547 }
1548}