Skip to main content

pacenotes_installer\engine/
plan.rs

1/// One step of a plan, for a `game/directory/name`.
2#[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    /// Selected without a verified payload, so the download phase failed or the cache lost it.
19    NothingToInstallFrom { package: String },
20    /// A dependency that is not selected, which the user can fix by ticking it.
21    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    /// Dependency refs resolve within this game.
43    pub game: &'a str,
44    /// Everything the folder should hold afterwards, kept packages included,
45    /// in install order, which decides who wins an overlap.
46    pub selection: &'a [String],
47    /// This game's single-pick categories, where a new pick supersedes the installed one.
48    pub choice_categories: &'a [&'a str],
49    pub installed: &'a std::collections::BTreeMap<String, crate::ledger::LedgerEntry>,
50    /// Cache entries whose payload is present.
51    pub available: &'a std::collections::BTreeMap<String, crate::tree::CachedPackage>,
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct Plan {
56    /// Every uninstall, then every install.
57    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
86/// Uninstalls always come first, because the style packages ship identical
87/// paths and the other order would delete what was just written.
88pub 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
100/// Installed packages that are unwanted, half-written by a dead run, outdated,
101/// or superseded by a new pick in the same group or choice category.
102fn 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
135/// The selection, in order, minus what is already installed at the same sha.
136fn 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
154/// A requirement counts only when selected: anything installed but unselected
155/// is uninstalled by this same plan.
156fn 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
177/// Whether installing `incoming` means `existing` goes: the same
178/// `exclusive_group`, or the same choice category, read from the keys so it
179/// works offline.
180fn 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;