use crate::error::GuixError;
use crate::types::Channel;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChannelsList {
Explicit(Vec<Channel>),
WithDefaults(Vec<Channel>),
}
impl ChannelsList {
pub fn channels(&self) -> &[Channel] {
match self {
ChannelsList::Explicit(v) | ChannelsList::WithDefaults(v) => v,
}
}
pub fn into_channels(self) -> Vec<Channel> {
match self {
ChannelsList::Explicit(v) | ChannelsList::WithDefaults(v) => v,
}
}
}
pub fn parse_channels(input: &str) -> Result<Vec<Channel>, GuixError> {
Ok(parse_channels_list(input)?.into_channels())
}
pub fn parse_channels_list(input: &str) -> Result<ChannelsList, GuixError> {
let mut parser = lexpr::Parser::from_str(input);
for v in parser.value_iter() {
let val = v.map_err(|e| GuixError::Parse(format!("channels: lexpr parse: {e}")))?;
let head = match val
.list_iter()
.and_then(|mut it| it.next().and_then(|h| h.as_symbol().map(str::to_owned)))
{
Some(h) => h,
None => continue,
};
match head.as_str() {
"list" => return Ok(ChannelsList::Explicit(parse_channel_elements(&val)?)),
"cons*" | "cons" => {
return Ok(ChannelsList::WithDefaults(parse_cons_elements(
&val, &head,
)?))
}
_ => continue,
}
}
Err(GuixError::Parse(
"channels: no `list` / `cons*` / `cons` form found".into(),
))
}
fn parse_channel_elements(val: &lexpr::Value) -> Result<Vec<Channel>, GuixError> {
let mut iter = val
.list_iter()
.ok_or_else(|| GuixError::Parse("channels: list form is not a list".into()))?;
let _ = iter.next();
let mut out = Vec::new();
for elt in iter {
if let Some(ch) = parse_channel_or_wrapper(elt)? {
out.push(ch);
}
}
Ok(out)
}
fn parse_cons_elements(val: &lexpr::Value, head_name: &str) -> Result<Vec<Channel>, GuixError> {
let mut iter = val
.list_iter()
.ok_or_else(|| GuixError::Parse(format!("channels: {head_name} form is not a list")))?;
let _ = iter.next();
let elements: Vec<&lexpr::Value> = iter.collect();
let Some((tail, head_elements)) = elements.split_last() else {
return Err(GuixError::Parse(format!(
"channels: {head_name} form has no elements"
)));
};
if tail.as_symbol() != Some("%default-channels") {
return Err(GuixError::Parse(format!(
"channels: {head_name} form must end in `%default-channels`, got {tail:?}"
)));
}
if head_name == "cons" && head_elements.len() != 1 {
return Err(GuixError::Parse(format!(
"channels: `cons` form must have exactly one channel + tail, got {} channels",
head_elements.len()
)));
}
let mut out = Vec::new();
for elt in head_elements {
if let Some(ch) = parse_channel_or_wrapper(elt)? {
out.push(ch);
}
}
Ok(out)
}
fn parse_channel_or_wrapper(val: &lexpr::Value) -> Result<Option<Channel>, GuixError> {
let Some(mut it) = val.list_iter() else {
return Ok(None);
};
let Some(head) = it.next() else {
return Ok(None);
};
let head_sym = head.as_symbol();
if head_sym == Some("channel") {
return parse_channel(val).map(Some);
}
for inner in it {
if let Some(mut ii) = inner.list_iter() {
if let Some(ih) = ii.next() {
if ih.as_symbol() == Some("channel") {
return parse_channel(inner).map(Some);
}
}
}
}
Ok(None)
}
fn parse_channel(val: &lexpr::Value) -> Result<Channel, GuixError> {
let mut iter = val
.list_iter()
.ok_or_else(|| GuixError::Parse("channel: not a list".into()))?;
let head = iter
.next()
.ok_or_else(|| GuixError::Parse("channel: empty".into()))?;
if head.as_symbol() != Some("channel") {
return Err(GuixError::Parse(format!(
"channel: expected `channel` head, got {head:?}"
)));
}
let mut name = None;
let mut url = None;
let mut branch = None;
let mut commit = None;
let mut intro_commit = None;
let mut intro_fpr = None;
for field in iter {
let mut fi = match field.list_iter() {
Some(it) => it,
None => continue,
};
let Some(key) = fi.next().and_then(|v| v.as_symbol().map(str::to_owned)) else {
continue;
};
let value = fi.next();
match (key.as_str(), value) {
("name", Some(v)) => name = sym_or_string(v),
("url", Some(v)) => url = v.as_str().map(str::to_owned),
("branch", Some(v)) => branch = v.as_str().map(str::to_owned),
("commit", Some(v)) => commit = v.as_str().map(str::to_owned),
("introduction", Some(v)) => {
let (c, f) = parse_make_introduction(v);
intro_commit = c;
intro_fpr = f;
}
_ => {}
}
}
Ok(Channel {
name: name.ok_or_else(|| GuixError::Parse("channel: missing name".into()))?,
url: url.ok_or_else(|| GuixError::Parse("channel: missing url".into()))?,
branch,
commit,
introduction_commit: intro_commit,
introduction_fingerprint: intro_fpr,
})
}
fn sym_or_string(v: &lexpr::Value) -> Option<String> {
if let Some(mut it) = v.list_iter() {
if let Some(h) = it.next() {
if h.as_symbol() == Some("quote") {
if let Some(inner) = it.next() {
return inner.as_symbol().map(str::to_owned);
}
}
}
}
if let Some(s) = v.as_symbol() {
return Some(s.to_owned());
}
v.as_str().map(str::to_owned)
}
fn parse_make_introduction(v: &lexpr::Value) -> (Option<String>, Option<String>) {
let mut commit = None;
let mut fpr = None;
let Some(mut it) = v.list_iter() else {
return (commit, fpr);
};
if it.next().and_then(lexpr::Value::as_symbol) != Some("make-channel-introduction") {
return (commit, fpr);
}
if let Some(c) = it.next() {
commit = c.as_str().map(str::to_owned);
}
if let Some(fpr_form) = it.next() {
if let Some(mut fi) = fpr_form.list_iter() {
if fi.next().and_then(lexpr::Value::as_symbol) == Some("openpgp-fingerprint") {
if let Some(s) = fi.next().and_then(lexpr::Value::as_str) {
fpr = Some(s.to_owned());
}
}
}
}
(commit, fpr)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_channels_fixture() {
let s = include_str!("../../tests/fixtures/describe-channels.scm");
let chans = parse_channels(s).unwrap();
assert_eq!(chans.len(), 3);
assert_eq!(chans[0].name, "pantherx");
assert_eq!(chans[0].url, "https://codeberg.org/gofranz/panther.git");
assert_eq!(chans[0].branch.as_deref(), Some("master"));
assert_eq!(
chans[0].commit.as_deref(),
Some("d7dd8f5d95ad0c8ba6ca0928cf6627e8ad39a31c")
);
assert_eq!(
chans[0].introduction_commit.as_deref(),
Some("54b4056ac571611892c743b65f4c47dc298c49da")
);
assert!(chans[0]
.introduction_fingerprint
.as_deref()
.unwrap()
.starts_with("A36A D41E"));
assert_eq!(chans[1].name, "guix");
assert_eq!(chans[2].name, "nonguix");
}
#[test]
fn list_head_yields_explicit() {
let s = include_str!("../../tests/fixtures/channels/list-three.scm");
let cl = parse_channels_list(s).unwrap();
match cl {
ChannelsList::Explicit(v) => assert_eq!(v.len(), 3),
other @ ChannelsList::WithDefaults(_) => panic!("expected Explicit, got {other:?}"),
}
}
#[test]
fn cons_star_head_yields_with_defaults() {
let s = include_str!("../../tests/fixtures/channels/cons-star-defaults.scm");
let cl = parse_channels_list(s).unwrap();
match cl {
ChannelsList::WithDefaults(v) => {
assert_eq!(v.len(), 1);
assert_eq!(v[0].name, "pantherx");
}
other @ ChannelsList::Explicit(_) => panic!("expected WithDefaults, got {other:?}"),
}
}
#[test]
fn cons_head_yields_with_defaults() {
let s = include_str!("../../tests/fixtures/channels/cons-single.scm");
let cl = parse_channels_list(s).unwrap();
match cl {
ChannelsList::WithDefaults(v) => {
assert_eq!(v.len(), 1);
assert_eq!(v[0].name, "pantherx");
}
other @ ChannelsList::Explicit(_) => panic!("expected WithDefaults, got {other:?}"),
}
}
#[test]
fn minimal_channel_only_name_and_url() {
let s = include_str!("../../tests/fixtures/channels/minimal-channel.scm");
let chans = parse_channels(s).unwrap();
assert_eq!(chans.len(), 1);
assert_eq!(chans[0].name, "guix-pod");
assert!(chans[0].branch.is_none());
assert!(chans[0].commit.is_none());
assert!(chans[0].introduction_commit.is_none());
}
#[test]
fn no_introduction_is_lenient() {
let s = include_str!("../../tests/fixtures/channels/no-introduction.scm");
let chans = parse_channels(s).unwrap();
assert_eq!(chans.len(), 1);
assert!(chans[0].introduction_commit.is_none());
}
#[test]
fn preamble_use_modules_is_skipped() {
let s = include_str!("../../tests/fixtures/channels/lock-with-use-modules.scm");
let cl = parse_channels_list(s).unwrap();
match cl {
ChannelsList::Explicit(v) => assert_eq!(v.len(), 2),
other @ ChannelsList::WithDefaults(_) => panic!("expected Explicit, got {other:?}"),
}
}
#[test]
fn wrapper_form_walks_one_level_in() {
let s = include_str!("../../tests/fixtures/channels/wrapped-and-commented.scm");
let cl = parse_channels_list(s).unwrap();
let chans = cl.into_channels();
assert!(
chans.iter().any(|c| c.name == "guix"),
"expected wrapped `guix` channel to surface"
);
assert!(
chans.iter().any(|c| c.name == "nonguix"),
"expected `nonguix` channel"
);
}
}