Skip to main content

guix_scheme/
channels_file.rs

1//! Typed view over a `channels.scm` document.
2
3use crate::record::RecordView;
4use crate::{validate, Channel, Error};
5use scheme_edit::{list, quoted_sym, string_lit, sym, Document, Node};
6
7/// Top-level channels-form shape, preserved across read/edit so the
8/// user's stylistic choice survives a round-trip.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ChannelsShape {
11    /// `(list (channel ...) ...)` — every channel enumerated explicitly.
12    Explicit,
13    /// `(cons* ... %default-channels)` / `(cons ... %default-channels)`
14    /// or a bare `%default-channels` symbol.
15    WithDefaults,
16}
17
18#[derive(Debug)]
19pub struct ChannelsFile {
20    doc: Document,
21    form_index: usize,
22    shape: ChannelsShape,
23}
24
25impl ChannelsFile {
26    /// Finds the first top-level `list`/`cons`/`cons*` form (preamble
27    /// forms like `use-modules` are skipped) or a bare
28    /// `%default-channels` symbol.
29    pub fn parse(src: &str) -> Result<Self, Error> {
30        let doc = Document::parse(src)?;
31        let mut found = None;
32        for (i, form) in doc.forms().enumerate() {
33            if form.as_symbol() == Some("%default-channels") {
34                found = Some((i, ChannelsShape::WithDefaults));
35                break;
36            }
37            match form.head_symbol() {
38                Some("list") => {
39                    found = Some((i, ChannelsShape::Explicit));
40                    break;
41                }
42                Some(h @ ("cons" | "cons*")) => {
43                    let tail = form.list_nodes().last();
44                    if tail.and_then(Node::as_symbol) != Some("%default-channels") {
45                        return Err(Error::Invalid {
46                            field: "channels".into(),
47                            reason: format!("`{h}` form must end in `%default-channels`"),
48                        });
49                    }
50                    // `cons` takes two args in Scheme: one channel + tail.
51                    let elements = form.data_len().saturating_sub(2);
52                    if h == "cons" && elements != 1 {
53                        return Err(Error::Invalid {
54                            field: "channels".into(),
55                            reason: format!(
56                                "`cons` form must have exactly one channel + tail, got {elements} channels"
57                            ),
58                        });
59                    }
60                    found = Some((i, ChannelsShape::WithDefaults));
61                    break;
62                }
63                _ => {}
64            }
65        }
66        let (form_index, shape) = found.ok_or(Error::NoChannelsForm)?;
67        let cf = ChannelsFile {
68            doc,
69            form_index,
70            shape,
71        };
72        cf.extract()?;
73        Ok(cf)
74    }
75
76    pub fn shape(&self) -> ChannelsShape {
77        self.shape
78    }
79
80    /// The channels enumerated in the file (defaults stay implicit).
81    pub fn channels(&self) -> Vec<Channel> {
82        // parse() validated the form; edits preserve the invariant.
83        self.extract().unwrap_or_default()
84    }
85
86    /// Append (Explicit) or insert before the `%default-channels` tail
87    /// (WithDefaults). A `cons` head is promoted to `cons*`.
88    pub fn add_channel(&mut self, ch: &Channel) -> Result<(), Error> {
89        validate::channel_fields(ch)?;
90        if ch.introduction_commit.is_none() || ch.introduction_fingerprint.is_none() {
91            return Err(Error::MissingField {
92                record: "channel".into(),
93                field: "introduction".into(),
94            });
95        }
96        if self.channels().iter().any(|c| c.name == ch.name) {
97            return Err(Error::Invalid {
98                field: "name".into(),
99                reason: format!("channel `{}` already present", ch.name),
100            });
101        }
102        let node = channel_to_node(ch);
103        let shape = self.shape;
104        let form = self.form_mut();
105        if form.as_symbol() == Some("%default-channels") {
106            // Render the guix-style shape and reparse it, like the edit ops.
107            let text = format!("(cons* {}\n       %default-channels)", node.to_pretty(7));
108            *form = Document::parse(&text)
109                .ok()
110                .and_then(|doc| {
111                    doc.items.into_iter().find_map(|i| match i {
112                        scheme_edit::Item::Node(n) => Some(n),
113                        _ => None,
114                    })
115                })
116                .unwrap_or_else(|| list(vec![sym("cons*"), node, sym("%default-channels")]));
117            return Ok(());
118        }
119        match shape {
120            ChannelsShape::Explicit => form.push_child(node),
121            ChannelsShape::WithDefaults => {
122                match form.position_of(|n| n.as_symbol() == Some("%default-channels")) {
123                    Some(tail) => form.insert_child(tail, node),
124                    None => form.push_child(node),
125                }
126                if form.head_symbol() == Some("cons") {
127                    form.replace_child(0, sym("cons*"));
128                }
129            }
130        }
131        Ok(())
132    }
133
134    /// Removes the whole element (wrapper included) plus attached leading
135    /// comments/blanks. A `cons`/`cons*` form left with only the tail
136    /// collapses to `(cons* %default-channels)`.
137    pub fn remove_channel(&mut self, name: &str) -> Result<(), Error> {
138        {
139            let form = self.form_mut();
140            let Some(idx) = form.position_of(|n| element_channel_name(n).as_deref() == Some(name))
141            else {
142                return Err(Error::ChannelNotFound(name.to_string()));
143            };
144            form.remove_child(idx, true);
145        }
146        if self.shape == ChannelsShape::WithDefaults && self.channels().is_empty() {
147            *self.form_mut() = list(vec![sym("cons*"), sym("%default-channels")]);
148        }
149        Ok(())
150    }
151
152    fn form(&self) -> &Node {
153        // form_index is fixed at parse; edits never drop top-level forms.
154        self.doc
155            .forms()
156            .nth(self.form_index)
157            .expect("channels form present")
158    }
159
160    fn form_mut(&mut self) -> &mut Node {
161        // form_index is fixed at parse; edits never drop top-level forms.
162        self.doc
163            .forms_mut()
164            .nth(self.form_index)
165            .expect("channels form present")
166    }
167
168    fn extract(&self) -> Result<Vec<Channel>, Error> {
169        let form = self.form();
170        if form.as_symbol() == Some("%default-channels") {
171            return Ok(Vec::new());
172        }
173        let mut out = Vec::new();
174        for elt in form.list_nodes().skip(1) {
175            if elt.as_symbol() == Some("%default-channels") {
176                continue;
177            }
178            if let Some(ch) = find_inner_channel(elt) {
179                out.push(parse_channel(ch)?);
180            }
181        }
182        Ok(out)
183    }
184}
185
186impl std::fmt::Display for ChannelsFile {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        self.doc.fmt(f)
189    }
190}
191
192/// Field order matches libguix `channel_sexp`.
193fn channel_to_node(ch: &Channel) -> Node {
194    let mut fields = vec![
195        sym("channel"),
196        list(vec![sym("name"), quoted_sym(&ch.name)]),
197        list(vec![sym("url"), string_lit(&ch.url)]),
198    ];
199    if let Some(b) = &ch.branch {
200        fields.push(list(vec![sym("branch"), string_lit(b)]));
201    }
202    if let Some(c) = &ch.commit {
203        fields.push(list(vec![sym("commit"), string_lit(c)]));
204    }
205    if let (Some(ic), Some(fpr)) = (&ch.introduction_commit, &ch.introduction_fingerprint) {
206        fields.push(list(vec![
207            sym("introduction"),
208            list(vec![
209                sym("make-channel-introduction"),
210                string_lit(ic),
211                list(vec![sym("openpgp-fingerprint"), string_lit(fpr)]),
212            ]),
213        ]));
214    }
215    list(fields)
216}
217
218fn element_channel_name(elt: &Node) -> Option<String> {
219    let v = RecordView::new(find_inner_channel(elt)?)?;
220    v.symbol_field("name")
221        .map(str::to_owned)
222        .or_else(|| v.string_field("name"))
223}
224
225/// Walks one level into wrapper forms like
226/// `(channel-with-substitutes-available (channel ...) "url")`.
227/// Non-list garbage yields `None` and is skipped silently.
228fn find_inner_channel(elt: &Node) -> Option<&Node> {
229    if elt.head_symbol() == Some("channel") {
230        return Some(elt);
231    }
232    elt.head_symbol()?;
233    elt.list_nodes()
234        .skip(1)
235        .find(|n| n.head_symbol() == Some("channel"))
236}
237
238fn parse_channel(node: &Node) -> Result<Channel, Error> {
239    let missing = |field: &str| Error::MissingField {
240        record: "channel".into(),
241        field: field.into(),
242    };
243    let v = RecordView::new(node).ok_or_else(|| missing("name"))?;
244    let name = v
245        .symbol_field("name")
246        .map(str::to_owned)
247        .or_else(|| v.string_field("name"))
248        .ok_or_else(|| missing("name"))?;
249    let url = v.string_field("url").ok_or_else(|| missing("url"))?;
250    let (introduction_commit, introduction_fingerprint) = match v.field("introduction") {
251        Some(intro) => parse_introduction(intro),
252        None => (None, None),
253    };
254    Ok(Channel {
255        name,
256        url,
257        branch: v.string_field("branch"),
258        commit: v.string_field("commit"),
259        introduction_commit,
260        introduction_fingerprint,
261    })
262}
263
264/// `(make-channel-introduction "<commit>" (openpgp-fingerprint "<fpr>"))`
265fn parse_introduction(node: &Node) -> (Option<String>, Option<String>) {
266    if node.head_symbol() != Some("make-channel-introduction") {
267        return (None, None);
268    }
269    let commit = node.data_child(1).and_then(Node::as_string_lit);
270    let fpr = node
271        .data_child(2)
272        .filter(|n| n.head_symbol() == Some("openpgp-fingerprint"))
273        .and_then(|n| n.data_child(1))
274        .and_then(Node::as_string_lit);
275    (commit, fpr)
276}