guix-scheme 0.1.0

Read, edit, and generate Guix Scheme files (channels.scm, system config.scm) from Rust
Documentation
//! Builder for `(operating-system ...)` system configurations, mirroring
//! what guix-install renders.

use scheme_edit::{list, quoted_sym, string_lit, sym, Document, Item, Node};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsFlavor {
    Guix,
    Nonguix,
    /// `(inherit %os-base)` via `(px system os)`.
    Panther,
}

#[derive(Debug, Clone)]
pub enum FsDevice {
    /// `(file-system-label "x")`
    Label(String),
    /// `(uuid "x")`
    Uuid(String),
    /// `(uuid "x" 'tag)`, e.g. `(uuid "ABCD-1234" 'fat32)`
    UuidWithType(String, String),
    /// `"x"`
    Path(String),
}

#[derive(Debug, Clone)]
enum LuksSource {
    Path(String),
    Uuid(String),
}

#[derive(Debug, Clone)]
enum Bootloader {
    Efi(String),
    Bios(String),
}

#[derive(Debug, Clone)]
struct FileSystemSpec {
    mount_point: String,
    device: FsDevice,
    fs_type: String,
    dependencies_mapped: bool,
}

#[derive(Debug, Clone)]
struct UserSpec {
    name: String,
    comment: String,
    groups: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct OsBuilder {
    flavor: OsFlavor,
    host_name: String,
    timezone: String,
    locale: String,
    keyboard_layout: Option<(String, Option<String>)>,
    bootloader: Option<Bootloader>,
    luks: Option<(LuksSource, String)>,
    file_systems: Vec<FileSystemSpec>,
    users: Vec<UserSpec>,
    packages: Option<String>,
    services: Option<String>,
    extra_fields: Vec<(String, String)>,
    extra_modules: Vec<String>,
}

impl OsBuilder {
    pub fn new(flavor: OsFlavor, host_name: &str, timezone: &str, locale: &str) -> Self {
        Self {
            flavor,
            host_name: host_name.to_string(),
            timezone: timezone.to_string(),
            locale: locale.to_string(),
            keyboard_layout: None,
            bootloader: None,
            luks: None,
            file_systems: Vec::new(),
            users: Vec::new(),
            packages: None,
            services: None,
            extra_fields: Vec::new(),
            extra_modules: Vec::new(),
        }
    }

    pub fn keyboard_layout(mut self, layout: &str, variant: Option<&str>) -> Self {
        self.keyboard_layout = Some((layout.to_string(), variant.map(str::to_string)));
        self
    }

    /// grub-efi-bootloader with `(targets (list esp))`.
    pub fn bootloader_efi(mut self, esp_mount: &str) -> Self {
        self.bootloader = Some(Bootloader::Efi(esp_mount.to_string()));
        self
    }

    /// grub-bootloader targeting the whole device.
    pub fn bootloader_bios(mut self, device: &str) -> Self {
        self.bootloader = Some(Bootloader::Bios(device.to_string()));
        self
    }

    /// LUKS mapping with a device-path source: `(source "<partition>")`.
    pub fn luks_root(mut self, partition: &str, mapped_name: &str) -> Self {
        self.luks = Some((
            LuksSource::Path(partition.to_string()),
            mapped_name.to_string(),
        ));
        self
    }

    /// LUKS mapping with a UUID source: `(source (uuid "<uuid>"))`.
    pub fn luks_root_uuid(mut self, uuid: &str, mapped_name: &str) -> Self {
        self.luks = Some((LuksSource::Uuid(uuid.to_string()), mapped_name.to_string()));
        self
    }

    pub fn file_system(
        mut self,
        mount_point: &str,
        device: FsDevice,
        fs_type: &str,
        dependencies_mapped: bool,
    ) -> Self {
        self.file_systems.push(FileSystemSpec {
            mount_point: mount_point.to_string(),
            device,
            fs_type: fs_type.to_string(),
            dependencies_mapped,
        });
        self
    }

    pub fn user(mut self, name: &str, comment: &str, groups: &[&str]) -> Self {
        self.users.push(UserSpec {
            name: name.to_string(),
            comment: comment.to_string(),
            groups: groups.iter().map(|g| (*g).to_string()).collect(),
        });
        self
    }

    /// Raw scheme expression, e.g. `%os-desktop-packages`.
    pub fn packages_field(mut self, expr_source: &str) -> Self {
        self.packages = Some(expr_source.to_string());
        self
    }

    pub fn services_field(mut self, expr_source: &str) -> Self {
        self.services = Some(expr_source.to_string());
        self
    }

    /// Escape hatch: value parsed via `Document::parse` at build time.
    pub fn extra_field(mut self, field: &str, expr_source: &str) -> Self {
        self.extra_fields
            .push((field.to_string(), expr_source.to_string()));
        self
    }

    /// Additional `(use-modules ...)` lines.
    pub fn use_modules(mut self, modules: &[&str]) -> Self {
        self.extra_modules
            .extend(modules.iter().map(|m| (*m).to_string()));
        self
    }

    /// Preamble + pretty-printed os form + trailing newline.
    ///
    /// # Panics
    ///
    /// Panics when an `expr_source` is not valid Scheme; builder misuse
    /// is a programming error.
    pub fn build(self) -> String {
        let mut out = String::new();
        for line in self.preamble_lines() {
            out.push_str(&line);
            out.push('\n');
        }
        out.push('\n');
        out.push_str(&self.os_node().to_pretty(0));
        out.push('\n');
        out
    }

    fn preamble_lines(&self) -> Vec<String> {
        let mut lines = vec![
            "(use-modules (gnu))".to_string(),
            "(use-service-modules networking ssh desktop)".to_string(),
        ];
        match self.flavor {
            OsFlavor::Guix => {}
            OsFlavor::Nonguix => {
                lines.push("(use-modules (nongnu packages linux))".to_string());
                lines.push("(use-modules (nongnu system linux-initrd))".to_string());
            }
            OsFlavor::Panther => {
                lines.push("(use-modules (px system os))".to_string());
            }
        }
        for m in &self.extra_modules {
            lines.push(format!("(use-modules {m})"));
        }
        lines
    }

    fn os_node(&self) -> Node {
        let mut fields = vec![sym("operating-system")];
        if self.flavor == OsFlavor::Panther {
            fields.push(list(vec![sym("inherit"), sym("%os-base")]));
        }
        fields.push(list(vec![sym("host-name"), string_lit(&self.host_name)]));
        fields.push(list(vec![sym("timezone"), string_lit(&self.timezone)]));
        fields.push(list(vec![sym("locale"), string_lit(&self.locale)]));
        if let Some((layout, variant)) = &self.keyboard_layout {
            let mut kb = vec![sym("keyboard-layout"), string_lit(layout)];
            if let Some(v) = variant {
                kb.push(string_lit(v));
            }
            fields.push(list(vec![sym("keyboard-layout"), list(kb)]));
        }
        if let Some(bl) = &self.bootloader {
            fields.push(self.bootloader_node(bl));
        }
        if self.flavor == OsFlavor::Nonguix {
            fields.push(list(vec![sym("kernel"), sym("linux")]));
            fields.push(list(vec![sym("initrd"), sym("microcode-initrd")]));
            fields.push(list(vec![
                sym("firmware"),
                list(vec![sym("list"), sym("linux-firmware")]),
            ]));
        }
        if let Some((source, name)) = &self.luks {
            let source_node = match source {
                LuksSource::Path(p) => string_lit(p),
                LuksSource::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
            };
            fields.push(list(vec![
                sym("mapped-devices"),
                list(vec![
                    sym("list"),
                    list(vec![
                        sym("mapped-device"),
                        list(vec![sym("source"), source_node]),
                        list(vec![sym("target"), string_lit(name)]),
                        list(vec![sym("type"), sym("luks-device-mapping")]),
                    ]),
                ]),
            ]));
        }
        if !self.file_systems.is_empty() {
            let mut fs_list = vec![sym("list")];
            fs_list.extend(self.file_systems.iter().map(file_system_node));
            fields.push(list(vec![
                sym("file-systems"),
                list(vec![
                    sym("append"),
                    list(fs_list),
                    sym("%base-file-systems"),
                ]),
            ]));
        }
        if !self.users.is_empty() {
            let mut accounts = vec![sym("list")];
            accounts.extend(self.users.iter().map(user_account_node));
            fields.push(list(vec![
                sym("users"),
                list(vec![
                    sym("append"),
                    list(accounts),
                    sym("%base-user-accounts"),
                ]),
            ]));
        } else if !self.extra_fields.iter().any(|(f, _)| f == "users") {
            fields.push(list(vec![sym("users"), sym("%base-user-accounts")]));
        }
        if let Some(p) = &self.packages {
            fields.push(list(vec![sym("packages"), parse_expr(p)]));
        }
        if let Some(s) = &self.services {
            fields.push(list(vec![sym("services"), parse_expr(s)]));
        }
        for (field, expr) in &self.extra_fields {
            fields.push(list(vec![sym(field), parse_expr(expr)]));
        }
        list(fields)
    }

    fn bootloader_node(&self, bl: &Bootloader) -> Node {
        let (bl_sym, target) = match bl {
            Bootloader::Efi(esp) => ("grub-efi-bootloader", esp),
            Bootloader::Bios(dev) => ("grub-bootloader", dev),
        };
        let mut cfg = vec![
            sym("bootloader-configuration"),
            list(vec![sym("bootloader"), sym(bl_sym)]),
            list(vec![
                sym("targets"),
                list(vec![sym("list"), string_lit(target)]),
            ]),
        ];
        if self.keyboard_layout.is_some() {
            cfg.push(list(vec![sym("keyboard-layout"), sym("keyboard-layout")]));
        }
        list(vec![sym("bootloader"), list(cfg)])
    }
}

fn file_system_node(fs: &FileSystemSpec) -> Node {
    let device = match &fs.device {
        FsDevice::Label(l) => list(vec![sym("file-system-label"), string_lit(l)]),
        FsDevice::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
        FsDevice::UuidWithType(u, tag) => list(vec![sym("uuid"), string_lit(u), quoted_sym(tag)]),
        FsDevice::Path(p) => string_lit(p),
    };
    let mut parts = vec![
        sym("file-system"),
        list(vec![sym("mount-point"), string_lit(&fs.mount_point)]),
        list(vec![sym("device"), device]),
        list(vec![sym("type"), string_lit(&fs.fs_type)]),
    ];
    if fs.dependencies_mapped {
        parts.push(list(vec![sym("dependencies"), sym("mapped-devices")]));
    }
    list(parts)
}

fn user_account_node(u: &UserSpec) -> Node {
    let mut groups = vec![sym("list")];
    groups.extend(u.groups.iter().map(|g| string_lit(g)));
    list(vec![
        sym("user-account"),
        list(vec![sym("name"), string_lit(&u.name)]),
        list(vec![sym("comment"), string_lit(&u.comment)]),
        list(vec![sym("group"), string_lit("users")]),
        list(vec![sym("supplementary-groups"), list(groups)]),
    ])
}

fn parse_expr(src: &str) -> Node {
    // Builder misuse is a programming error, matching guix-install's
    // static templates.
    let doc =
        Document::parse(src).unwrap_or_else(|e| panic!("invalid scheme expression `{src}`: {e}"));
    doc.items
        .into_iter()
        .find_map(|i| match i {
            Item::Node(n) => Some(n),
            _ => None,
        })
        .unwrap_or_else(|| panic!("empty scheme expression `{src}`"))
}