pacenotes_installer\engine/
plan.rs1#[derive(Clone, Debug, PartialEq, Eq)]
3pub enum Action {
4 Uninstall(String),
5 Install(String),
6}
7
8impl Action {
9 pub fn key(&self) -> &str {
10 match self {
11 Action::Uninstall(key) | Action::Install(key) => key,
12 }
13 }
14}
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum PlanError {
18 NothingToInstallFrom { package: String },
20 MissingRequirement { package: String, requires: String },
22}
23
24impl std::fmt::Display for PlanError {
25 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 PlanError::NothingToInstallFrom { package } => write!(
28 formatter,
29 "{package} was selected but has not been downloaded, so there is \
30 nothing to install from"
31 ),
32 PlanError::MissingRequirement { package, requires } => write!(
33 formatter,
34 "{package} needs {requires}, which is neither selected nor already \
35 installed"
36 ),
37 }
38 }
39}
40
41pub struct PlanInput<'a> {
42 pub game: &'a str,
44 pub selection: &'a [String],
47 pub choice_categories: &'a [&'a str],
49 pub installed: &'a std::collections::BTreeMap<String, crate::ledger::LedgerEntry>,
50 pub available: &'a std::collections::BTreeMap<String, crate::tree::CachedPackage>,
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct Plan {
56 pub actions: Vec<Action>,
58}
59
60impl Plan {
61 pub fn is_empty(&self) -> bool {
62 self.actions.is_empty()
63 }
64
65 pub fn uninstalls(&self) -> Vec<&str> {
66 self.actions
67 .iter()
68 .filter_map(|action| match action {
69 Action::Uninstall(key) => Some(key.as_str()),
70 Action::Install(_) => None,
71 })
72 .collect()
73 }
74
75 pub fn installs(&self) -> Vec<&str> {
76 self.actions
77 .iter()
78 .filter_map(|action| match action {
79 Action::Install(key) => Some(key.as_str()),
80 Action::Uninstall(_) => None,
81 })
82 .collect()
83 }
84}
85
86pub fn plan(input: &PlanInput) -> Result<Plan, PlanError> {
89 let selected: std::collections::BTreeSet<&str> =
90 input.selection.iter().map(String::as_str).collect();
91 let uninstall = packages_to_uninstall(input, &selected);
92 let install = packages_to_install(input)?;
93 check_requirements(input, &selected)?;
94
95 let mut actions: Vec<Action> = uninstall.into_iter().map(Action::Uninstall).collect();
96 actions.extend(install.into_iter().map(Action::Install));
97 Ok(Plan { actions })
98}
99
100fn packages_to_uninstall(
103 input: &PlanInput,
104 selected: &std::collections::BTreeSet<&str>,
105) -> std::collections::BTreeSet<String> {
106 let mut uninstall = std::collections::BTreeSet::new();
107 for (key, entry) in input.installed {
108 let unwanted = !selected.contains(key.as_str());
109 let unfinished = entry.state == crate::ledger::EntryState::Installing;
110 let outdated = input
111 .available
112 .get(key)
113 .is_some_and(|cached| cached.sha256 != entry.sha256);
114 if unwanted || unfinished || outdated {
115 uninstall.insert(key.clone());
116 }
117 }
118
119 for key in input.selection {
120 let Some(incoming) = input.available.get(key.as_str()) else {
121 continue;
122 };
123 for (installed_key, entry) in input.installed {
124 if installed_key != key
125 && !selected.contains(installed_key.as_str())
126 && supersedes(key, incoming, installed_key, entry, input.choice_categories)
127 {
128 uninstall.insert(installed_key.clone());
129 }
130 }
131 }
132 uninstall
133}
134
135fn packages_to_install(input: &PlanInput) -> Result<Vec<String>, PlanError> {
137 let mut install = Vec::new();
138 for key in input.selection {
139 let Some(cached) = input.available.get(key.as_str()) else {
140 return Err(PlanError::NothingToInstallFrom {
141 package: key.clone(),
142 });
143 };
144 let already_installed = input.installed.get(key.as_str()).is_some_and(|entry| {
145 entry.sha256 == cached.sha256 && entry.state == crate::ledger::EntryState::Installed
146 });
147 if !already_installed {
148 install.push(key.clone());
149 }
150 }
151 Ok(install)
152}
153
154fn check_requirements(
157 input: &PlanInput,
158 selected: &std::collections::BTreeSet<&str>,
159) -> Result<(), PlanError> {
160 for key in input.selection {
161 let Some(cached) = input.available.get(key.as_str()) else {
162 continue;
163 };
164 for reference in &cached.requires {
165 let needed = format!("{}/{}", input.game, reference);
166 if !selected.contains(needed.as_str()) {
167 return Err(PlanError::MissingRequirement {
168 package: key.clone(),
169 requires: needed,
170 });
171 }
172 }
173 }
174 Ok(())
175}
176
177fn supersedes(
181 incoming_key: &str,
182 incoming: &crate::tree::CachedPackage,
183 existing_key: &str,
184 existing: &crate::ledger::LedgerEntry,
185 choice_categories: &[&str],
186) -> bool {
187 if let (Some(incoming_group), Some(existing_group)) =
188 (&incoming.exclusive_group, &existing.exclusive_group)
189 {
190 if incoming_group == existing_group {
191 return true;
192 }
193 }
194 match (
195 pacenotes_shared::category_of_download_path(incoming_key),
196 pacenotes_shared::category_of_download_path(existing_key),
197 ) {
198 (Some(incoming_category), Some(existing_category)) => {
199 incoming_category == existing_category && choice_categories.contains(&incoming_category)
200 }
201 _ => false,
202 }
203}
204
205#[cfg(test)]
206mod tests;