1use scheme_edit::{list, quoted_sym, string_lit, sym, Document, Item, Node};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum OsFlavor {
8 Guix,
9 Nonguix,
10 Panther,
12}
13
14#[derive(Debug, Clone)]
15pub enum FsDevice {
16 Label(String),
18 Uuid(String),
20 UuidWithType(String, String),
22 Path(String),
24}
25
26#[derive(Debug, Clone)]
27enum LuksSource {
28 Path(String),
29 Uuid(String),
30}
31
32#[derive(Debug, Clone)]
33enum Bootloader {
34 Efi(String),
35 Bios(String),
36}
37
38#[derive(Debug, Clone)]
39struct FileSystemSpec {
40 mount_point: String,
41 device: FsDevice,
42 fs_type: String,
43 dependencies_mapped: bool,
44}
45
46#[derive(Debug, Clone)]
47struct UserSpec {
48 name: String,
49 comment: String,
50 groups: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
54pub struct OsBuilder {
55 flavor: OsFlavor,
56 host_name: String,
57 timezone: String,
58 locale: String,
59 keyboard_layout: Option<(String, Option<String>)>,
60 bootloader: Option<Bootloader>,
61 luks: Option<(LuksSource, String)>,
62 file_systems: Vec<FileSystemSpec>,
63 swap_space_file: Option<String>,
64 users: Vec<UserSpec>,
65 packages: Option<String>,
66 services: Option<String>,
67 extra_fields: Vec<(String, String)>,
68 extra_modules: Vec<String>,
69}
70
71impl OsBuilder {
72 pub fn new(flavor: OsFlavor, host_name: &str, timezone: &str, locale: &str) -> Self {
73 Self {
74 flavor,
75 host_name: host_name.to_string(),
76 timezone: timezone.to_string(),
77 locale: locale.to_string(),
78 keyboard_layout: None,
79 bootloader: None,
80 luks: None,
81 file_systems: Vec::new(),
82 swap_space_file: None,
83 users: Vec::new(),
84 packages: None,
85 services: None,
86 extra_fields: Vec::new(),
87 extra_modules: Vec::new(),
88 }
89 }
90
91 pub fn keyboard_layout(mut self, layout: &str, variant: Option<&str>) -> Self {
92 self.keyboard_layout = Some((layout.to_string(), variant.map(str::to_string)));
93 self
94 }
95
96 pub fn bootloader_efi(mut self, esp_mount: &str) -> Self {
98 self.bootloader = Some(Bootloader::Efi(esp_mount.to_string()));
99 self
100 }
101
102 pub fn bootloader_bios(mut self, device: &str) -> Self {
104 self.bootloader = Some(Bootloader::Bios(device.to_string()));
105 self
106 }
107
108 pub fn luks_root(mut self, partition: &str, mapped_name: &str) -> Self {
110 self.luks = Some((
111 LuksSource::Path(partition.to_string()),
112 mapped_name.to_string(),
113 ));
114 self
115 }
116
117 pub fn luks_root_uuid(mut self, uuid: &str, mapped_name: &str) -> Self {
119 self.luks = Some((LuksSource::Uuid(uuid.to_string()), mapped_name.to_string()));
120 self
121 }
122
123 pub fn file_system(
124 mut self,
125 mount_point: &str,
126 device: FsDevice,
127 fs_type: &str,
128 dependencies_mapped: bool,
129 ) -> Self {
130 self.file_systems.push(FileSystemSpec {
131 mount_point: mount_point.to_string(),
132 device,
133 fs_type: fs_type.to_string(),
134 dependencies_mapped,
135 });
136 self
137 }
138
139 pub fn swap_space_file(mut self, target: &str) -> Self {
141 self.swap_space_file = Some(target.to_string());
142 self
143 }
144
145 pub fn user(mut self, name: &str, comment: &str, groups: &[&str]) -> Self {
146 self.users.push(UserSpec {
147 name: name.to_string(),
148 comment: comment.to_string(),
149 groups: groups.iter().map(|g| (*g).to_string()).collect(),
150 });
151 self
152 }
153
154 pub fn packages_field(mut self, expr_source: &str) -> Self {
158 self.packages = Some(expr_source.to_string());
159 self
160 }
161
162 pub fn services_field(mut self, expr_source: &str) -> Self {
166 self.services = Some(expr_source.to_string());
167 self
168 }
169
170 pub fn extra_field(mut self, field: &str, expr_source: &str) -> Self {
174 self.extra_fields
175 .push((field.to_string(), expr_source.to_string()));
176 self
177 }
178
179 pub fn use_modules(mut self, modules: &[&str]) -> Self {
181 self.extra_modules
182 .extend(modules.iter().map(|m| (*m).to_string()));
183 self
184 }
185
186 pub fn try_build(&self) -> Result<String, crate::Error> {
194 let mut out = String::new();
195 for line in self.preamble_lines() {
196 out.push_str(&line);
197 out.push('\n');
198 }
199 out.push('\n');
200 out.push_str(&self.try_os_node()?.to_pretty(0));
201 out.push('\n');
202 Ok(out)
203 }
204
205 pub fn build(self) -> String {
212 self.try_build().unwrap_or_else(|e| panic!("{e}"))
213 }
214
215 fn preamble_lines(&self) -> Vec<String> {
216 let mut lines = vec![
217 "(use-modules (gnu))".to_string(),
218 "(use-service-modules networking ssh desktop)".to_string(),
219 ];
220 match self.flavor {
221 OsFlavor::Guix => {}
222 OsFlavor::Nonguix => {
223 lines.push("(use-modules (nongnu packages linux))".to_string());
224 lines.push("(use-modules (nongnu system linux-initrd))".to_string());
225 }
226 OsFlavor::Panther => {
227 lines.push("(use-modules (px system os))".to_string());
228 }
229 }
230 for m in &self.extra_modules {
231 lines.push(format!("(use-modules {m})"));
232 }
233 lines
234 }
235
236 fn try_os_node(&self) -> Result<Node, crate::Error> {
237 let mut fields = vec![sym("operating-system")];
238 if self.flavor == OsFlavor::Panther {
239 fields.push(list(vec![sym("inherit"), sym("%os-base")]));
240 }
241 fields.push(list(vec![sym("host-name"), string_lit(&self.host_name)]));
242 fields.push(list(vec![sym("timezone"), string_lit(&self.timezone)]));
243 fields.push(list(vec![sym("locale"), string_lit(&self.locale)]));
244 if let Some((layout, variant)) = &self.keyboard_layout {
245 let mut kb = vec![sym("keyboard-layout"), string_lit(layout)];
246 if let Some(v) = variant {
247 kb.push(string_lit(v));
248 }
249 fields.push(list(vec![sym("keyboard-layout"), list(kb)]));
250 }
251 if let Some(bl) = &self.bootloader {
252 fields.push(self.bootloader_node(bl));
253 }
254 if self.flavor == OsFlavor::Nonguix {
255 fields.push(list(vec![sym("kernel"), sym("linux")]));
256 fields.push(list(vec![sym("initrd"), sym("microcode-initrd")]));
257 fields.push(list(vec![
258 sym("firmware"),
259 list(vec![sym("list"), sym("linux-firmware")]),
260 ]));
261 }
262 if let Some((source, name)) = &self.luks {
263 let source_node = match source {
264 LuksSource::Path(p) => string_lit(p),
265 LuksSource::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
266 };
267 fields.push(list(vec![
268 sym("mapped-devices"),
269 list(vec![
270 sym("list"),
271 list(vec![
272 sym("mapped-device"),
273 list(vec![sym("source"), source_node]),
274 list(vec![sym("target"), string_lit(name)]),
275 list(vec![sym("type"), sym("luks-device-mapping")]),
276 ]),
277 ]),
278 ]));
279 }
280 if !self.file_systems.is_empty() {
281 let mut fs_list = vec![sym("list")];
282 fs_list.extend(self.file_systems.iter().map(file_system_node));
283 fields.push(list(vec![
284 sym("file-systems"),
285 list(vec![
286 sym("append"),
287 list(fs_list),
288 sym("%base-file-systems"),
289 ]),
290 ]));
291 }
292 if let Some(target) = &self.swap_space_file {
293 fields.push(list(vec![
294 sym("swap-devices"),
295 list(vec![
296 sym("list"),
297 list(vec![
298 sym("swap-space"),
299 list(vec![sym("target"), string_lit(target)]),
300 ]),
301 ]),
302 ]));
303 }
304 if !self.users.is_empty() {
305 let mut accounts = vec![sym("list")];
306 accounts.extend(self.users.iter().map(user_account_node));
307 fields.push(list(vec![
308 sym("users"),
309 list(vec![
310 sym("append"),
311 list(accounts),
312 sym("%base-user-accounts"),
313 ]),
314 ]));
315 } else if !self.extra_fields.iter().any(|(f, _)| f == "users") {
316 fields.push(list(vec![sym("users"), sym("%base-user-accounts")]));
317 }
318 if let Some(p) = &self.packages {
319 fields.push(list(vec![sym("packages"), try_parse_expr("packages", p)?]));
320 }
321 if let Some(s) = &self.services {
322 fields.push(list(vec![sym("services"), try_parse_expr("services", s)?]));
323 }
324 for (field, expr) in &self.extra_fields {
325 fields.push(list(vec![sym(field), try_parse_expr(field, expr)?]));
326 }
327 Ok(list(fields))
328 }
329
330 fn bootloader_node(&self, bl: &Bootloader) -> Node {
331 let (bl_sym, target) = match bl {
332 Bootloader::Efi(esp) => ("grub-efi-bootloader", esp),
333 Bootloader::Bios(dev) => ("grub-bootloader", dev),
334 };
335 let mut cfg = vec![
336 sym("bootloader-configuration"),
337 list(vec![sym("bootloader"), sym(bl_sym)]),
338 list(vec![
339 sym("targets"),
340 list(vec![sym("list"), string_lit(target)]),
341 ]),
342 ];
343 if self.keyboard_layout.is_some() {
344 cfg.push(list(vec![sym("keyboard-layout"), sym("keyboard-layout")]));
345 }
346 list(vec![sym("bootloader"), list(cfg)])
347 }
348}
349
350fn file_system_node(fs: &FileSystemSpec) -> Node {
351 let device = match &fs.device {
352 FsDevice::Label(l) => list(vec![sym("file-system-label"), string_lit(l)]),
353 FsDevice::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
354 FsDevice::UuidWithType(u, tag) => list(vec![sym("uuid"), string_lit(u), quoted_sym(tag)]),
355 FsDevice::Path(p) => string_lit(p),
356 };
357 let mut parts = vec![
358 sym("file-system"),
359 list(vec![sym("mount-point"), string_lit(&fs.mount_point)]),
360 list(vec![sym("device"), device]),
361 list(vec![sym("type"), string_lit(&fs.fs_type)]),
362 ];
363 if fs.dependencies_mapped {
364 parts.push(list(vec![sym("dependencies"), sym("mapped-devices")]));
365 }
366 list(parts)
367}
368
369fn user_account_node(u: &UserSpec) -> Node {
370 let mut groups = vec![sym("list")];
371 groups.extend(u.groups.iter().map(|g| string_lit(g)));
372 list(vec![
373 sym("user-account"),
374 list(vec![sym("name"), string_lit(&u.name)]),
375 list(vec![sym("comment"), string_lit(&u.comment)]),
376 list(vec![sym("group"), string_lit("users")]),
377 list(vec![sym("supplementary-groups"), list(groups)]),
378 ])
379}
380
381fn try_parse_expr(field: &str, src: &str) -> Result<Node, crate::Error> {
382 let doc = Document::parse(src).map_err(|e| crate::Error::Invalid {
383 field: field.into(),
384 reason: format!("invalid scheme expression `{src}`: {e}"),
385 })?;
386 doc.items
387 .into_iter()
388 .find_map(|i| match i {
389 Item::Node(n) => Some(n),
390 _ => None,
391 })
392 .ok_or_else(|| crate::Error::Invalid {
393 field: field.into(),
394 reason: format!("empty scheme expression `{src}`"),
395 })
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::Error;
402
403 #[test]
404 fn try_build_ok_for_valid_fields() {
405 let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
406 .packages_field("%base-packages")
407 .services_field("(list)")
408 .try_build()
409 .expect("valid fields should build");
410 assert!(out.contains("(packages %base-packages)"));
411 assert!(out.contains("(services (list))"));
412 }
413
414 #[test]
415 fn try_build_err_on_invalid_packages() {
416 let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
417 .packages_field("(unclosed")
418 .try_build()
419 .unwrap_err();
420 assert!(matches!(err, Error::Invalid { field, .. } if field == "packages"));
421 }
422
423 #[test]
424 fn try_build_err_on_invalid_services() {
425 let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
426 .services_field("(oops")
427 .try_build()
428 .unwrap_err();
429 assert!(matches!(err, Error::Invalid { field, .. } if field == "services"));
430 }
431
432 #[test]
433 fn try_build_err_on_invalid_extra_field() {
434 let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
435 .extra_field("swap-devices", "(bad")
436 .try_build()
437 .unwrap_err();
438 assert!(matches!(err, Error::Invalid { field, .. } if field == "swap-devices"));
439 }
440
441 #[test]
442 fn try_build_err_on_empty_expression() {
443 let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
444 .packages_field(" ")
445 .try_build()
446 .unwrap_err();
447 match err {
448 Error::Invalid { field, reason } => {
449 assert_eq!(field, "packages");
450 assert!(reason.contains("empty"), "reason was: {reason}");
451 }
452 other => panic!("expected Invalid, got {other:?}"),
453 }
454 }
455
456 #[test]
457 fn build_produces_expected_string_for_valid_builder() {
458 let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
459 .packages_field("%base-packages")
460 .build();
461 assert!(out.starts_with("(use-modules (gnu))\n"));
462 assert!(out.contains("(operating-system"));
463 assert!(out.contains("(host-name \"h\")"));
464 assert!(out.contains("(packages %base-packages)"));
465 assert!(out.ends_with('\n'));
466 }
467
468 #[test]
469 fn swap_space_file_emits_swap_space_target() {
470 let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
471 .file_system("/", FsDevice::Label("root".into()), "ext4", false)
472 .swap_space_file("/swapfile")
473 .build();
474 assert!(
475 out.contains("(swap-devices (list (swap-space (target \"/swapfile\"))))"),
476 "output was:\n{out}"
477 );
478 }
479
480 #[test]
481 fn swap_positioned_between_file_systems_and_users() {
482 let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
483 .file_system("/", FsDevice::Label("root".into()), "ext4", false)
484 .swap_space_file("/swapfile")
485 .user("alice", "Alice", &["wheel"])
486 .build();
487 let fs = out.find("file-systems").expect("file-systems present");
488 let swap = out.find("swap-devices").expect("swap-devices present");
489 let users = out.find("users").expect("users present");
490 assert!(fs < swap && swap < users, "ordering wrong in:\n{out}");
491 }
492
493 #[test]
494 #[should_panic(expected = "invalid packages")]
495 fn build_panics_on_invalid_field() {
496 let _ = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
497 .packages_field("(unclosed")
498 .build();
499 }
500}