1use 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#[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#[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 pub id: String,
52 pub entries: Vec<String>,
53}
54
55#[derive(Clone, Debug, Default, Serialize)]
57pub struct Survey {
58 pub distribution: String,
59 pub family: Option<String>,
61 pub package_manager: Option<String>,
62 pub tools: Vec<String>,
65 pub desktop: Option<Kind>,
66 pub desktop_name: Option<String>,
68 pub gpus: Vec<String>,
69 pub laptop: bool,
70}
71
72#[derive(Clone, Debug, Serialize)]
74pub struct Suggestion {
75 pub id: String,
76 pub name: String,
77 pub available: bool,
78 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
94pub 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
103pub 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 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 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 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 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
214pub 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 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 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}