guix-scheme 0.2.0

Read, edit, and generate Guix Scheme files (channels.scm, system config.scm) from Rust
Documentation
//! Typed view over a services expression (the value of a config's
//! `services` field). Blessed shapes are edited; everything else is
//! refused without touching the tree.

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shape {
    /// `(append (list <elt>...) <tail-expr>)`
    AppendList,
    /// `(list <elt>...)`
    List,
    /// `(cons* <elt>... <tail>)`
    ConsStar,
    /// `(cons <elt> <tail>)`
    Cons,
    /// e.g. `%desktop-services`
    BareSymbol,
    /// Detected but read-only; edits refuse.
    ModifyServices,
    Unknown,
}

fn shape_of(node: &Node) -> Shape {
    if node.as_symbol().is_some() {
        return Shape::BareSymbol;
    }
    match node.head_symbol() {
        Some("append") if node.list_nodes().any(|n| n.head_symbol() == Some("list")) => {
            Shape::AppendList
        }
        Some("list") => Shape::List,
        Some("cons*") => Shape::ConsStar,
        Some("cons") => Shape::Cons,
        Some("modify-services") => Shape::ModifyServices,
        _ => Shape::Unknown,
    }
}

/// The list node holding the `(service ...)` elements.
fn container(node: &Node) -> Option<&Node> {
    match shape_of(node) {
        Shape::AppendList => node.list_nodes().find(|n| n.head_symbol() == Some("list")),
        Shape::List | Shape::Cons | Shape::ConsStar => Some(node),
        _ => None,
    }
}

fn is_service_of(node: &Node, type_sym: &str) -> bool {
    node.head_symbol() == Some("service")
        && node.data_child(1).and_then(Node::as_symbol) == Some(type_sym)
}

fn collect_service_types(node: &Node) -> Vec<String> {
    let Some(c) = container(node) else {
        return Vec::new();
    };
    c.list_nodes()
        .skip(1)
        .filter(|n| n.head_symbol() == Some("service"))
        .filter_map(|n| n.data_child(1)?.as_symbol().map(str::to_owned))
        .collect()
}

fn unsupported() -> Error {
    Error::Invalid {
        field: "services".into(),
        reason: "unsupported services shape for editing".into(),
    }
}

fn parse_config_expr(src: &str) -> Result<Node, Error> {
    let doc = Document::parse(src).map_err(|e| Error::Invalid {
        field: "services".into(),
        reason: format!("invalid config expression: {e}"),
    })?;
    doc.items
        .into_iter()
        .find_map(|i| match i {
            Item::Node(n) => Some(n),
            _ => None,
        })
        .ok_or_else(|| Error::Invalid {
            field: "services".into(),
            reason: "empty config expression".into(),
        })
}

/// Pretty-print and reparse so multi-line text nests as stored source.
fn reflow(node: Node) -> Node {
    match Document::parse(&node.to_pretty(0)) {
        Ok(doc) => doc
            .items
            .into_iter()
            .find_map(|i| match i {
                Item::Node(n) => Some(n),
                _ => None,
            })
            .unwrap_or(node),
        Err(_) => node,
    }
}

/// Read-only view over a services expression node.
pub struct ServicesView<'a> {
    node: &'a Node,
}

impl<'a> ServicesView<'a> {
    pub fn new(node: &'a Node) -> Self {
        Self { node }
    }

    /// Type symbols of `(service <type> ...)` elements; other elements
    /// are skipped. Empty for bare symbols and `modify-services`.
    pub fn service_types(&self) -> Vec<String> {
        collect_service_types(self.node)
    }

    pub fn has_service(&self, type_sym: &str) -> bool {
        self.service_types().iter().any(|t| t == type_sym)
    }
}

/// Mutable view: same reads plus add/remove/replace.
pub struct ServicesViewMut<'a> {
    node: &'a mut Node,
}

impl<'a> ServicesViewMut<'a> {
    pub fn new(node: &'a mut Node) -> Self {
        Self { node }
    }

    pub fn service_types(&self) -> Vec<String> {
        collect_service_types(self.node)
    }

    pub fn has_service(&self, type_sym: &str) -> bool {
        self.service_types().iter().any(|t| t == type_sym)
    }

    /// Builds `(service <sym>)` or `(service <sym> <config>)` and adds
    /// it to the blessed shape. A bare base symbol is rewritten to
    /// `(append (list (service <sym>)) <base>)`; `cons` promotes to
    /// `cons*`.
    pub fn add_service(
        &mut self,
        type_sym: &str,
        config_source: Option<&str>,
    ) -> Result<(), Error> {
        let shape = shape_of(self.node);
        if matches!(shape, Shape::ModifyServices | Shape::Unknown) {
            return Err(unsupported());
        }
        if self.has_service(type_sym) {
            return Err(Error::Invalid {
                field: "services".into(),
                reason: format!("service `{type_sym}` already present"),
            });
        }
        let mut parts = vec![sym("service"), sym(type_sym)];
        if let Some(cfg) = config_source {
            parts.push(parse_config_expr(cfg)?);
        }
        let svc = list(parts);
        match shape {
            Shape::AppendList => self
                .container_mut()
                .ok_or_else(unsupported)?
                .push_child(svc),
            Shape::List => self.node.push_child(svc),
            Shape::Cons | Shape::ConsStar => {
                let tail = self.node.data_len().saturating_sub(1);
                self.node.insert_child(tail, svc);
                if shape == Shape::Cons {
                    self.node.replace_child(0, sym("cons*"));
                }
            }
            Shape::BareSymbol => {
                let base = self.node.as_symbol().unwrap_or_default().to_string();
                *self.node = reflow(list(vec![
                    sym("append"),
                    list(vec![sym("list"), svc]),
                    sym(&base),
                ]));
            }
            Shape::ModifyServices | Shape::Unknown => unreachable!("checked above"),
        }
        Ok(())
    }

    /// Removes the element with attached leading trivia. A `cons`/`cons*`
    /// left with only its tail collapses to `(cons* <tail>)` (single-arg
    /// `cons*` is identity; a one-arg `cons` would be invalid Scheme).
    pub fn remove_service(&mut self, type_sym: &str) -> Result<(), Error> {
        let shape = shape_of(self.node);
        {
            let Some(c) = self.container_mut() else {
                return Err(unsupported());
            };
            let Some(idx) = c.position_of(|n| is_service_of(n, type_sym)) else {
                return Err(Error::Invalid {
                    field: "services".into(),
                    reason: format!("service `{type_sym}` not found"),
                });
            };
            c.remove_child(idx, true);
        }
        if matches!(shape, Shape::Cons | Shape::ConsStar) && self.node.data_len() == 2 {
            if let Some(tail) = self.node.data_child(1).cloned() {
                *self.node = list(vec![sym("cons*"), tail]);
            }
        }
        Ok(())
    }

    /// Swaps just the type symbol; a config argument is preserved.
    pub fn replace_service_type(&mut self, old: &str, new: &str) -> bool {
        let Some(c) = self.container_mut() else {
            return false;
        };
        let Some(idx) = c.position_of(|n| is_service_of(n, old)) else {
            return false;
        };
        match c.data_child_mut(idx) {
            Some(el) => el.replace_child(1, sym(new)),
            None => false,
        }
    }

    fn container_mut(&mut self) -> Option<&mut Node> {
        match shape_of(self.node) {
            Shape::AppendList => {
                // Edits require the (list ...) as append's FIRST argument;
                // reads stay lenient about its position.
                if self.node.data_child(1)?.head_symbol() != Some("list") {
                    return None;
                }
                self.node.data_child_mut(1)
            }
            Shape::List | Shape::Cons | Shape::ConsStar => Some(&mut *self.node),
            _ => None,
        }
    }
}