guix-scheme 0.2.0

Read, edit, and generate Guix Scheme files (channels.scm, system config.scm) from Rust
Documentation
//! Read and mutate view over an `(operating-system ...)` form.
//!
//! Built on top of [`RecordView`](crate::record::RecordView): fields are
//! read by name, and disk-layout fields (`file-systems`, `mapped-devices`)
//! are edited in place across the container shapes that appear in real
//! configs, leaving untouched nodes byte-for-byte intact.

use crate::record::{RecordView, RecordViewMut};
use crate::Error;
use scheme_edit::{list, sym, Document, Item, Node};

fn is_os(node: &Node) -> bool {
    node.head_symbol() == Some("operating-system")
}

fn invalid(field: &str, reason: impl Into<String>) -> Error {
    Error::Invalid {
        field: field.into(),
        reason: reason.into(),
    }
}

/// Parse SRC and return its first datum node.
fn parse_single_datum(src: &str, field: &str) -> Result<Node, Error> {
    let doc = Document::parse(src).map_err(|e| invalid(field, format!("invalid source: {e}")))?;
    doc.items
        .into_iter()
        .find_map(|i| match i {
            Item::Node(n) => Some(n),
            _ => None,
        })
        .ok_or_else(|| invalid(field, "no datum in source"))
}

/// Splice ELT into a list-like FIELD value, preserving untouched nodes.
///
/// Shapes: `(list ...)` appends, `(cons* ... tail)` / `(cons elt tail)`
/// insert before the tail (`cons` promotes to `cons*`), `(append (list
/// ...) tail)` appends into the inner list, and a bare symbol becomes
/// `(cons* elt <symbol>)`.
fn splice_into_container(val: &mut Node, elt: Node, field: &str) -> Result<(), Error> {
    if let Some(base) = val.as_symbol() {
        let base = base.to_string();
        *val = list(vec![sym("cons*"), elt, sym(&base)]);
        return Ok(());
    }
    match val.head_symbol() {
        Some("list") => {
            val.push_child(elt);
            Ok(())
        }
        Some("cons*") => {
            let tail = val.data_len().saturating_sub(1);
            val.insert_child(tail, elt);
            Ok(())
        }
        Some("cons") => {
            let tail = val.data_len().saturating_sub(1);
            val.insert_child(tail, elt);
            val.replace_child(0, sym("cons*"));
            Ok(())
        }
        Some("append") => {
            let idx = val.position_of(|n| n.head_symbol() == Some("list"));
            match idx.and_then(|i| val.data_child_mut(i)) {
                Some(inner) => {
                    inner.push_child(elt);
                    Ok(())
                }
                None => Err(invalid(field, "unsupported `append` shape for editing")),
            }
        }
        _ => Err(invalid(field, "unsupported container shape for editing")),
    }
}

/// Read-only view over an `(operating-system ...)` form. An optional
/// leading `(inherit ...)` is accepted and ignored by field lookups.
pub struct OperatingSystemView<'a> {
    record: RecordView<'a>,
}

impl<'a> OperatingSystemView<'a> {
    /// `Some` only when NODE is a list headed by `operating-system`.
    pub fn new(node: &'a Node) -> Option<Self> {
        if !is_os(node) {
            return None;
        }
        Some(Self {
            record: RecordView::new(node)?,
        })
    }

    pub fn host_name(&self) -> Option<String> {
        self.record.string_field("host-name")
    }

    pub fn timezone(&self) -> Option<String> {
        self.record.string_field("timezone")
    }

    pub fn locale(&self) -> Option<String> {
        self.record.string_field("locale")
    }

    /// Value node of a named field, e.g. `"file-systems"` or `"services"`.
    pub fn field(&self, name: &str) -> Option<&'a Node> {
        self.record.field(name)
    }

    pub fn has_field(&self, name: &str) -> bool {
        self.record.field(name).is_some()
    }
}

/// Mutable view: field reads plus field and disk-layout editing.
pub struct OperatingSystemViewMut<'a> {
    node: &'a mut Node,
}

impl<'a> OperatingSystemViewMut<'a> {
    /// `Some` only when NODE is a list headed by `operating-system`.
    pub fn new(node: &'a mut Node) -> Option<Self> {
        if !is_os(node) {
            return None;
        }
        Some(Self { node })
    }

    // new() guards the head symbol, so RecordViewMut always builds.
    fn record_mut(&mut self) -> RecordViewMut<'_> {
        RecordViewMut::new(&mut *self.node).expect("operating-system form")
    }

    /// Parse VALUE_SOURCE to a datum and set (or create) the named field.
    pub fn set_field(&mut self, name: &str, value_source: &str) -> Result<(), Error> {
        let value = parse_single_datum(value_source, name)?;
        self.record_mut().set_field(name, value);
        Ok(())
    }

    /// Splice a single `(file-system ...)` datum into `file-systems`,
    /// creating `(file-systems (cons* <fs> %base-file-systems))` if the
    /// field is absent.
    pub fn add_file_system(&mut self, fs_source: &str) -> Result<(), Error> {
        let fs = parse_single_datum(fs_source, "file-systems")?;
        if fs.head_symbol() != Some("file-system") {
            return Err(invalid(
                "file-systems",
                "expected a (file-system ...) datum",
            ));
        }
        {
            let mut record = self.record_mut();
            if let Some(val) = record.field_mut("file-systems") {
                return splice_into_container(val, fs, "file-systems");
            }
        }
        self.record_mut().set_field(
            "file-systems",
            list(vec![sym("cons*"), fs, sym("%base-file-systems")]),
        );
        Ok(())
    }

    /// Splice a single `(mapped-device ...)` datum into `mapped-devices`,
    /// creating `(mapped-devices (list <md>))` if the field is absent.
    pub fn add_mapped_device(&mut self, md_source: &str) -> Result<(), Error> {
        let md = parse_single_datum(md_source, "mapped-devices")?;
        if md.head_symbol() != Some("mapped-device") {
            return Err(invalid(
                "mapped-devices",
                "expected a (mapped-device ...) datum",
            ));
        }
        {
            let mut record = self.record_mut();
            if let Some(val) = record.field_mut("mapped-devices") {
                return splice_into_container(val, md, "mapped-devices");
            }
        }
        self.record_mut()
            .set_field("mapped-devices", list(vec![sym("list"), md]));
        Ok(())
    }
}