guix-scheme 0.1.0

Read, edit, and generate Guix Scheme files (channels.scm, system config.scm) from Rust
Documentation
//! Typed view over a `channels.scm` document.

use crate::record::RecordView;
use crate::{validate, Channel, Error};
use scheme_edit::{list, quoted_sym, string_lit, sym, Document, Node};

/// Top-level channels-form shape, preserved across read/edit so the
/// user's stylistic choice survives a round-trip.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelsShape {
    /// `(list (channel ...) ...)` — every channel enumerated explicitly.
    Explicit,
    /// `(cons* ... %default-channels)` / `(cons ... %default-channels)`
    /// or a bare `%default-channels` symbol.
    WithDefaults,
}

#[derive(Debug)]
pub struct ChannelsFile {
    doc: Document,
    form_index: usize,
    shape: ChannelsShape,
}

impl ChannelsFile {
    /// Finds the first top-level `list`/`cons`/`cons*` form (preamble
    /// forms like `use-modules` are skipped) or a bare
    /// `%default-channels` symbol.
    pub fn parse(src: &str) -> Result<Self, Error> {
        let doc = Document::parse(src)?;
        let mut found = None;
        for (i, form) in doc.forms().enumerate() {
            if form.as_symbol() == Some("%default-channels") {
                found = Some((i, ChannelsShape::WithDefaults));
                break;
            }
            match form.head_symbol() {
                Some("list") => {
                    found = Some((i, ChannelsShape::Explicit));
                    break;
                }
                Some(h @ ("cons" | "cons*")) => {
                    let tail = form.list_nodes().last();
                    if tail.and_then(Node::as_symbol) != Some("%default-channels") {
                        return Err(Error::Invalid {
                            field: "channels".into(),
                            reason: format!("`{h}` form must end in `%default-channels`"),
                        });
                    }
                    // `cons` takes two args in Scheme: one channel + tail.
                    let elements = form.data_len().saturating_sub(2);
                    if h == "cons" && elements != 1 {
                        return Err(Error::Invalid {
                            field: "channels".into(),
                            reason: format!(
                                "`cons` form must have exactly one channel + tail, got {elements} channels"
                            ),
                        });
                    }
                    found = Some((i, ChannelsShape::WithDefaults));
                    break;
                }
                _ => {}
            }
        }
        let (form_index, shape) = found.ok_or(Error::NoChannelsForm)?;
        let cf = ChannelsFile {
            doc,
            form_index,
            shape,
        };
        cf.extract()?;
        Ok(cf)
    }

    pub fn shape(&self) -> ChannelsShape {
        self.shape
    }

    /// The channels enumerated in the file (defaults stay implicit).
    pub fn channels(&self) -> Vec<Channel> {
        // parse() validated the form; edits preserve the invariant.
        self.extract().unwrap_or_default()
    }

    /// Append (Explicit) or insert before the `%default-channels` tail
    /// (WithDefaults). A `cons` head is promoted to `cons*`.
    pub fn add_channel(&mut self, ch: &Channel) -> Result<(), Error> {
        validate::channel_fields(ch)?;
        if ch.introduction_commit.is_none() || ch.introduction_fingerprint.is_none() {
            return Err(Error::MissingField {
                record: "channel".into(),
                field: "introduction".into(),
            });
        }
        if self.channels().iter().any(|c| c.name == ch.name) {
            return Err(Error::Invalid {
                field: "name".into(),
                reason: format!("channel `{}` already present", ch.name),
            });
        }
        let node = channel_to_node(ch);
        let shape = self.shape;
        let form = self.form_mut();
        if form.as_symbol() == Some("%default-channels") {
            // Render the guix-style shape and reparse it, like the edit ops.
            let text = format!("(cons* {}\n       %default-channels)", node.to_pretty(7));
            *form = Document::parse(&text)
                .ok()
                .and_then(|doc| {
                    doc.items.into_iter().find_map(|i| match i {
                        scheme_edit::Item::Node(n) => Some(n),
                        _ => None,
                    })
                })
                .unwrap_or_else(|| list(vec![sym("cons*"), node, sym("%default-channels")]));
            return Ok(());
        }
        match shape {
            ChannelsShape::Explicit => form.push_child(node),
            ChannelsShape::WithDefaults => {
                match form.position_of(|n| n.as_symbol() == Some("%default-channels")) {
                    Some(tail) => form.insert_child(tail, node),
                    None => form.push_child(node),
                }
                if form.head_symbol() == Some("cons") {
                    form.replace_child(0, sym("cons*"));
                }
            }
        }
        Ok(())
    }

    /// Removes the whole element (wrapper included) plus attached leading
    /// comments/blanks. A `cons`/`cons*` form left with only the tail
    /// collapses to `(cons* %default-channels)`.
    pub fn remove_channel(&mut self, name: &str) -> Result<(), Error> {
        {
            let form = self.form_mut();
            let Some(idx) = form.position_of(|n| element_channel_name(n).as_deref() == Some(name))
            else {
                return Err(Error::ChannelNotFound(name.to_string()));
            };
            form.remove_child(idx, true);
        }
        if self.shape == ChannelsShape::WithDefaults && self.channels().is_empty() {
            *self.form_mut() = list(vec![sym("cons*"), sym("%default-channels")]);
        }
        Ok(())
    }

    fn form(&self) -> &Node {
        // form_index is fixed at parse; edits never drop top-level forms.
        self.doc
            .forms()
            .nth(self.form_index)
            .expect("channels form present")
    }

    fn form_mut(&mut self) -> &mut Node {
        // form_index is fixed at parse; edits never drop top-level forms.
        self.doc
            .forms_mut()
            .nth(self.form_index)
            .expect("channels form present")
    }

    fn extract(&self) -> Result<Vec<Channel>, Error> {
        let form = self.form();
        if form.as_symbol() == Some("%default-channels") {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for elt in form.list_nodes().skip(1) {
            if elt.as_symbol() == Some("%default-channels") {
                continue;
            }
            if let Some(ch) = find_inner_channel(elt) {
                out.push(parse_channel(ch)?);
            }
        }
        Ok(out)
    }
}

impl std::fmt::Display for ChannelsFile {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.doc.fmt(f)
    }
}

/// Field order matches libguix `channel_sexp`.
fn channel_to_node(ch: &Channel) -> Node {
    let mut fields = vec![
        sym("channel"),
        list(vec![sym("name"), quoted_sym(&ch.name)]),
        list(vec![sym("url"), string_lit(&ch.url)]),
    ];
    if let Some(b) = &ch.branch {
        fields.push(list(vec![sym("branch"), string_lit(b)]));
    }
    if let Some(c) = &ch.commit {
        fields.push(list(vec![sym("commit"), string_lit(c)]));
    }
    if let (Some(ic), Some(fpr)) = (&ch.introduction_commit, &ch.introduction_fingerprint) {
        fields.push(list(vec![
            sym("introduction"),
            list(vec![
                sym("make-channel-introduction"),
                string_lit(ic),
                list(vec![sym("openpgp-fingerprint"), string_lit(fpr)]),
            ]),
        ]));
    }
    list(fields)
}

fn element_channel_name(elt: &Node) -> Option<String> {
    let v = RecordView::new(find_inner_channel(elt)?)?;
    v.symbol_field("name")
        .map(str::to_owned)
        .or_else(|| v.string_field("name"))
}

/// Walks one level into wrapper forms like
/// `(channel-with-substitutes-available (channel ...) "url")`.
/// Non-list garbage yields `None` and is skipped silently.
fn find_inner_channel(elt: &Node) -> Option<&Node> {
    if elt.head_symbol() == Some("channel") {
        return Some(elt);
    }
    elt.head_symbol()?;
    elt.list_nodes()
        .skip(1)
        .find(|n| n.head_symbol() == Some("channel"))
}

fn parse_channel(node: &Node) -> Result<Channel, Error> {
    let missing = |field: &str| Error::MissingField {
        record: "channel".into(),
        field: field.into(),
    };
    let v = RecordView::new(node).ok_or_else(|| missing("name"))?;
    let name = v
        .symbol_field("name")
        .map(str::to_owned)
        .or_else(|| v.string_field("name"))
        .ok_or_else(|| missing("name"))?;
    let url = v.string_field("url").ok_or_else(|| missing("url"))?;
    let (introduction_commit, introduction_fingerprint) = match v.field("introduction") {
        Some(intro) => parse_introduction(intro),
        None => (None, None),
    };
    Ok(Channel {
        name,
        url,
        branch: v.string_field("branch"),
        commit: v.string_field("commit"),
        introduction_commit,
        introduction_fingerprint,
    })
}

/// `(make-channel-introduction "<commit>" (openpgp-fingerprint "<fpr>"))`
fn parse_introduction(node: &Node) -> (Option<String>, Option<String>) {
    if node.head_symbol() != Some("make-channel-introduction") {
        return (None, None);
    }
    let commit = node.data_child(1).and_then(Node::as_string_lit);
    let fpr = node
        .data_child(2)
        .filter(|n| n.head_symbol() == Some("openpgp-fingerprint"))
        .and_then(|n| n.data_child(1))
        .and_then(Node::as_string_lit);
    (commit, fpr)
}