Skip to main content

helixforge_engine/
setup.rs

1//! The setup wizard: what this machine is (distribution, desktop, graphics,
2//! package tools), the workstations it offers (`catalog/setup.toml`), and
3//! which of their entries this machine can run. The wizard only queues
4//! entries for an ordinary Install Plan; it runs nothing itself.
5
6use crate::{Machine, desktop::Kind, plan_with};
7use helixforge_catalog::Catalog;
8use helixforge_core::{PackageManager, printable};
9use helixforge_platform::LinuxInfo;
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12
13/// `catalog/setup.toml`.
14#[derive(Clone, Debug, Default, Deserialize)]
15pub struct Setup {
16    #[serde(default, rename = "workstation")]
17    pub workstations: Vec<Workstation>,
18    #[serde(default, rename = "desktop")]
19    pub desktops: Vec<DesktopEntries>,
20}
21
22#[derive(Clone, Debug, Default, Deserialize, Serialize)]
23pub struct Workstation {
24    pub id: String,
25    pub name: String,
26    #[serde(default)]
27    pub description: String,
28    #[serde(default)]
29    pub entries: Vec<String>,
30    #[serde(default)]
31    pub when: Vec<When>,
32}
33
34/// Entries added when every condition given holds.
35#[derive(Clone, Debug, Default, Deserialize, Serialize)]
36pub struct When {
37    #[serde(default)]
38    pub gpu: Option<String>,
39    #[serde(default)]
40    pub family: Option<String>,
41    #[serde(default)]
42    pub not_family: Option<String>,
43    #[serde(default)]
44    pub desktop: Option<String>,
45    pub entries: Vec<String>,
46}
47
48#[derive(Clone, Debug, Default, Deserialize)]
49pub struct DesktopEntries {
50    /// A desktop's id, or `any`.
51    pub id: String,
52    pub entries: Vec<String>,
53}
54
55/// What the wizard learns about the machine.
56#[derive(Clone, Debug, Default, Serialize)]
57pub struct Survey {
58    pub distribution: String,
59    /// arch, debian, fedora, suse, …
60    pub family: Option<String>,
61    pub package_manager: Option<String>,
62    /// Package tools besides the native one: paru, yay, flatpak, nix,
63    /// home-manager, snap.
64    pub tools: Vec<String>,
65    pub desktop: Option<Kind>,
66    /// "KDE Plasma 6.8.0 on Wayland".
67    pub desktop_name: Option<String>,
68    pub gpus: Vec<String>,
69    pub laptop: bool,
70}
71
72/// One suggested entry and whether it can run here.
73#[derive(Clone, Debug, Serialize)]
74pub struct Suggestion {
75    pub id: String,
76    pub name: String,
77    pub available: bool,
78    /// Why it cannot run here, or what is worth knowing.
79    pub note: Option<String>,
80}
81
82pub fn family_of(manager: Option<PackageManager>) -> Option<&'static str> {
83    Some(match manager? {
84        PackageManager::Arch => "arch",
85        PackageManager::Debian => "debian",
86        PackageManager::Fedora => "fedora",
87        PackageManager::Suse => "suse",
88        PackageManager::Alpine => "alpine",
89        PackageManager::Void => "void",
90        PackageManager::Solus => "solus",
91    })
92}
93
94/// Read `catalog/setup.toml` from a catalog's root.
95pub fn load(root: &Path) -> Result<Setup, String> {
96    let path = root.join("catalog/setup.toml");
97    let text =
98        std::fs::read_to_string(&path).map_err(|error| format!("{}: {error}", path.display()))?;
99    toml::from_str(&text)
100        .map_err(|error| format!("{}: {}", path.display(), printable(error.message())))
101}
102
103/// Look at the machine. `on_path` answers whether a tool is installed.
104pub fn survey(
105    platform: &LinuxInfo,
106    desktop: &crate::desktop::Desktop,
107    gpus: Vec<String>,
108    laptop: bool,
109    on_path: &dyn Fn(&str) -> bool,
110) -> Survey {
111    Survey {
112        distribution: printable(&platform.pretty_name),
113        family: family_of(platform.manager).map(String::from),
114        package_manager: platform.manager.map(|manager| manager.label().to_string()),
115        tools: ["paru", "yay", "flatpak", "nix", "home-manager", "snap"]
116            .into_iter()
117            .filter(|tool| on_path(tool))
118            .map(String::from)
119            .collect(),
120        desktop: desktop.kind,
121        desktop_name: desktop.name.as_ref().map(|name| match &desktop.session {
122            Some(session) => format!(
123                "{name} on {}",
124                match session.as_str() {
125                    "wayland" => "Wayland".to_string(),
126                    "x11" => "X11".to_string(),
127                    other => other.to_string(),
128                }
129            ),
130            None => name.clone(),
131        }),
132        gpus,
133        laptop,
134    }
135}
136
137impl When {
138    fn holds(&self, survey: &Survey) -> bool {
139        let family = survey.family.as_deref();
140        self.gpu
141            .as_ref()
142            .is_none_or(|gpu| survey.gpus.contains(gpu))
143            && self
144                .family
145                .as_deref()
146                .is_none_or(|wanted| family == Some(wanted))
147            && self
148                .not_family
149                .as_deref()
150                .is_none_or(|unwanted| family != Some(unwanted))
151            && self
152                .desktop
153                .as_deref()
154                .is_none_or(|wanted| survey.desktop.map(Kind::id) == Some(wanted))
155    }
156}
157
158impl Workstation {
159    /// Its entries for this machine, in order, once each.
160    pub fn entries_for(&self, survey: &Survey) -> Vec<String> {
161        let mut ids: Vec<String> = Vec::new();
162        let conditional = self
163            .when
164            .iter()
165            .filter(|when| when.holds(survey))
166            .flat_map(|when| when.entries.iter());
167        for id in self.entries.iter().chain(conditional) {
168            if !ids.contains(id) {
169                ids.push(id.clone());
170            }
171        }
172        ids
173    }
174}
175
176impl Setup {
177    /// Every workstation's entries, for a custom choice.
178    pub fn everything(&self, survey: &Survey) -> Vec<String> {
179        let mut ids: Vec<String> = Vec::new();
180        for workstation in &self.workstations {
181            for id in workstation.entries_for(survey) {
182                if !ids.contains(&id) {
183                    ids.push(id);
184                }
185            }
186        }
187        ids
188    }
189
190    /// The entries offered for a desktop: its own, then those for any.
191    pub fn for_desktop(&self, kind: Option<Kind>) -> Vec<String> {
192        let mut ids: Vec<String> = Vec::new();
193        for group in &self.desktops {
194            if group.id == "any" || kind.map(Kind::id) == Some(group.id.as_str()) {
195                for id in &group.entries {
196                    if !ids.contains(id) {
197                        ids.push(id.clone());
198                    }
199                }
200            }
201        }
202        // The desktop's own first.
203        ids.sort_by_key(|id| {
204            !self.desktops.iter().any(|group| {
205                group.id != "any"
206                    && kind.map(Kind::id) == Some(group.id.as_str())
207                    && group.entries.contains(id)
208            })
209        });
210        ids
211    }
212}
213
214/// Which suggested entries this machine can run: the catalog has them here
215/// (preconditions hide some) and they can be planned for this distribution.
216pub fn suggestions(ids: &[String], catalog: &Catalog, platform: &LinuxInfo) -> Vec<Suggestion> {
217    let nothing = Machine {
218        holds: &|_| false,
219        on_path: &|_| false,
220    };
221    ids.iter()
222        .map(|id| match catalog.find(id) {
223            None => Suggestion {
224                id: id.clone(),
225                name: printable(id),
226                available: false,
227                note: Some("not offered on this machine".to_string()),
228            },
229            Some(entry) => {
230                let planned = plan_with([entry], platform, &|id| catalog.find(id), nothing);
231                Suggestion {
232                    id: id.clone(),
233                    name: entry.name.clone(),
234                    available: planned.is_ok(),
235                    note: match planned {
236                        Ok(_) if !entry.multi_select => {
237                            Some("runs on its own, outside the Install Plan".to_string())
238                        }
239                        Ok(_) => None,
240                        Err(_) => Some(format!(
241                            "no package for {}",
242                            printable(&platform.pretty_name)
243                        )),
244                    },
245                }
246            }
247        })
248        .collect()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn arch() -> LinuxInfo {
256        LinuxInfo {
257            id: "arch".into(),
258            pretty_name: "Arch Linux".into(),
259            id_like: vec![],
260            manager: Some(PackageManager::Arch),
261        }
262    }
263
264    fn fedora() -> LinuxInfo {
265        LinuxInfo {
266            id: "fedora".into(),
267            pretty_name: "Fedora Linux".into(),
268            id_like: vec![],
269            manager: Some(PackageManager::Fedora),
270        }
271    }
272
273    #[test]
274    fn workstations_fit_the_machine() {
275        let catalog = Catalog::builtin(false).unwrap();
276        let setup = load(catalog.root()).unwrap();
277        let ids: Vec<&str> = setup.workstations.iter().map(|w| w.id.as_str()).collect();
278        assert_eq!(ids, ["developer", "gaming", "privacy", "minimal", "custom"]);
279        // Every name the file gives is a catalog entry.
280        for id in setup.everything(&Survey::default()).iter().chain(
281            setup
282                .workstations
283                .iter()
284                .flat_map(|w| w.when.iter().flat_map(|when| when.entries.iter())),
285        ) {
286            assert!(catalog.find(id).is_some(), "setup.toml names {id}");
287        }
288        let desktop = crate::desktop::Desktop::default();
289        let on_arch = survey(
290            &arch(),
291            &desktop,
292            vec!["nvidia".into(), "intel".into()],
293            true,
294            &|tool| tool == "paru",
295        );
296        assert_eq!(on_arch.tools, ["paru"]);
297        let gaming = setup
298            .workstations
299            .iter()
300            .find(|w| w.id == "gaming")
301            .unwrap();
302        assert!(
303            gaming
304                .entries_for(&on_arch)
305                .contains(&"nvidia-drivers-hardware-acceleration".to_string())
306        );
307        let on_fedora = survey(&fedora(), &desktop, vec!["nvidia".into()], false, &|_| {
308            false
309        });
310        let entries = gaming.entries_for(&on_fedora);
311        assert!(entries.contains(&"nvidia-proprietary-drivers".to_string()));
312        assert!(!entries.contains(&"nvidia-drivers-hardware-acceleration".to_string()));
313        let privacy = setup
314            .workstations
315            .iter()
316            .find(|w| w.id == "privacy")
317            .unwrap();
318        assert!(
319            privacy
320                .entries_for(&on_fedora)
321                .contains(&"selinux".to_string())
322        );
323        assert!(
324            privacy
325                .entries_for(&on_arch)
326                .contains(&"apparmor".to_string())
327        );
328        assert!(
329            setup
330                .workstations
331                .iter()
332                .find(|w| w.id == "custom")
333                .unwrap()
334                .entries_for(&on_arch)
335                .is_empty()
336        );
337        // What this machine can run.
338        let found = suggestions(&gaming.entries_for(&on_arch), &catalog, &arch());
339        assert!(found.iter().find(|s| s.id == "steam").unwrap().available);
340        let kde = setup.for_desktop(Some(Kind::Kde));
341        assert_eq!(kde[0], "dotfiles-konsole");
342        assert!(kde.contains(&"plymouth-on".to_string()));
343        assert!(
344            !setup
345                .for_desktop(Some(Kind::Gnome))
346                .contains(&"dotfiles-konsole".to_string())
347        );
348    }
349}