mod common;
use std::path::PathBuf;
use libguix::{Channel, ChannelOp, ChannelsError, ChannelsFile, ChannelsList};
fn fixture(name: &str) -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/fixtures/channels");
p.push(name);
p
}
fn copy_to_tempdir(fixture_name: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("channels.scm");
std::fs::copy(fixture(fixture_name), &dst).expect("copy fixture");
(dir, dst)
}
#[tokio::test]
async fn parse_list_three_yields_three_explicit_channels() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
match &cf.list {
ChannelsList::Explicit(v) => {
assert_eq!(v.len(), 3);
assert_eq!(v[0].name, "pantherx");
assert_eq!(v[1].name, "guix");
assert_eq!(v[2].name, "nonguix");
}
other => panic!("expected Explicit, got {other:?}"),
}
}
#[tokio::test]
async fn parse_cons_star_defaults_is_with_defaults() {
let (_dir, path) = copy_to_tempdir("cons-star-defaults.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
assert!(matches!(cf.list, ChannelsList::WithDefaults(_)));
assert_eq!(cf.list.channels().len(), 1);
}
#[tokio::test]
async fn parse_cons_star_multi_is_with_defaults() {
let (_dir, path) = copy_to_tempdir("cons-star-multi.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
match &cf.list {
ChannelsList::WithDefaults(v) => assert_eq!(v.len(), 2),
other => panic!("expected WithDefaults, got {other:?}"),
}
}
#[tokio::test]
async fn parse_cons_single_is_with_defaults() {
let (_dir, path) = copy_to_tempdir("cons-single.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
assert!(matches!(cf.list, ChannelsList::WithDefaults(_)));
}
#[tokio::test]
async fn parse_lock_file_with_use_modules() {
let (_dir, path) = copy_to_tempdir("lock-with-use-modules.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
match &cf.list {
ChannelsList::Explicit(v) => assert_eq!(v.len(), 2),
other => panic!("expected Explicit, got {other:?}"),
}
}
#[tokio::test]
async fn parse_no_commit_lenient() {
let (_dir, path) = copy_to_tempdir("no-commit.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let chans = cf.list.channels();
assert_eq!(chans.len(), 1);
assert!(chans[0].commit.is_none());
assert_eq!(chans[0].branch.as_deref(), Some("master"));
}
#[tokio::test]
async fn parse_no_introduction_lenient() {
let (_dir, path) = copy_to_tempdir("no-introduction.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let chans = cf.list.channels();
assert_eq!(chans.len(), 1);
assert!(chans[0].introduction_commit.is_none());
}
#[tokio::test]
async fn parse_minimal_channel_only_name_url() {
let (_dir, path) = copy_to_tempdir("minimal-channel.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let chans = cf.list.channels();
assert_eq!(chans.len(), 1);
assert_eq!(chans[0].name, "guix-pod");
assert!(chans[0].branch.is_none());
}
#[tokio::test]
async fn parse_weird_indent_still_works() {
let (_dir, path) = copy_to_tempdir("weird-indent.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
assert!(matches!(cf.list, ChannelsList::WithDefaults(_)));
}
#[tokio::test]
async fn parse_with_top_level_comment_skips_preamble() {
let (_dir, path) = copy_to_tempdir("with-top-level-comment.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let chans = cf.list.channels();
assert_eq!(chans.len(), 1);
assert_eq!(chans[0].name, "nonguix");
}
#[tokio::test]
async fn parse_wrapped_and_commented_finds_inner_channels() {
let (_dir, path) = copy_to_tempdir("wrapped-and-commented.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let names: Vec<&str> = cf.list.channels().iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"guix"), "expected wrapped guix: {names:?}");
assert!(names.contains(&"nonguix"), "names={names:?}");
}
#[tokio::test]
async fn is_writable_true_for_plain_tempdir_file() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
assert!(cf.is_writable(), "tempdir file should be writable");
}
#[cfg(unix)]
#[tokio::test]
async fn is_writable_false_for_symlink_into_store() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let link = dir.path().join("channels.scm");
symlink(
"/gnu/store/00000000000000000000000000000000-channels/channels.scm",
&link,
)
.expect("symlink");
let target = std::fs::read_link(&link).expect("read_link");
assert!(target.to_string_lossy().starts_with("/gnu/store/"));
let real = dir.path().join("real.scm");
std::fs::copy(fixture("list-three.scm"), &real).expect("copy");
let cf = ChannelsFile::read(Some(&real)).await.expect("read real");
assert!(cf.is_writable(), "regular file must be writable");
}
#[tokio::test]
async fn validate_every_fixture_green() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
for name in [
"list-three.scm",
"cons-star-defaults.scm",
"cons-star-multi.scm",
"cons-single.scm",
"lock-with-use-modules.scm",
"no-commit.scm",
"no-introduction.scm",
"minimal-channel.scm",
"weird-indent.scm",
"with-top-level-comment.scm",
"wrapped-and-commented.scm",
] {
let src = std::fs::read_to_string(fixture(name)).expect("read fixture");
ChannelsFile::validate(&repl, &src)
.await
.unwrap_or_else(|e| panic!("validate({name}) failed: {e}"));
}
}
#[tokio::test]
async fn validate_synthetic_broken_returns_parse_error() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let bad = "(list (channel (name 'foo) (url \"https://x\")\n";
let err = ChannelsFile::validate(&repl, bad)
.await
.expect_err("expected parse error");
match err {
ChannelsError::ParseError { .. } => {}
other => panic!("expected ParseError, got {other:?}"),
}
}
fn synthesised_channel() -> Channel {
Channel {
name: "panther-test".into(),
url: "https://example/panther-test.git".into(),
branch: Some("master".into()),
commit: Some("f0e1d2c3b4a5".into()),
introduction_commit: Some("0000000000000000000000000000000000000000".into()),
introduction_fingerprint: Some("ABCD EF01 2345 6789 ABCD EF01 2345 6789 ABCD EF01".into()),
}
}
#[tokio::test]
async fn add_channel_to_list_three_appends() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let new_ch = synthesised_channel();
let new_src = cf
.apply(&repl, ChannelOp::AddChannel(new_ch.clone()))
.await
.expect("apply add");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
let reparsed = libguix::parse_channels_list(&new_src).expect("re-parse");
assert!(matches!(reparsed, ChannelsList::Explicit(_)));
let names: Vec<&str> = reparsed
.channels()
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(
names.contains(&"panther-test"),
"new channel missing from output: {names:?}"
);
assert_eq!(reparsed.channels().len(), 4);
}
#[tokio::test]
async fn add_channel_to_cons_star_defaults_inserts_before_default_channels() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("cons-star-defaults.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let new_ch = synthesised_channel();
let new_src = cf
.apply(&repl, ChannelOp::AddChannel(new_ch.clone()))
.await
.expect("apply add");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
let reparsed = libguix::parse_channels_list(&new_src).expect("re-parse");
assert!(
matches!(reparsed, ChannelsList::WithDefaults(_)),
"expected WithDefaults, got {reparsed:?}"
);
let names: Vec<&str> = reparsed
.channels()
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(names.contains(&"panther-test"));
assert_eq!(reparsed.channels().len(), 2);
}
#[tokio::test]
async fn add_channel_duplicate_name_rejected_in_preflight() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let dup = Channel {
name: "guix".into(),
url: "https://example/guix.git".into(),
branch: None,
commit: None,
introduction_commit: Some("00".into()),
introduction_fingerprint: Some("AA".into()),
};
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let err = cf
.apply(&repl, ChannelOp::AddChannel(dup))
.await
.expect_err("expected duplicate-name");
assert!(
matches!(err, ChannelsError::DuplicateName { ref name } if name == "guix"),
"wrong error: {err:?}"
);
}
#[tokio::test]
async fn remove_channel_from_list_three() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let new_src = cf
.apply(&repl, ChannelOp::RemoveChannelByName("nonguix".into()))
.await
.expect("apply remove");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
let reparsed = libguix::parse_channels_list(&new_src).expect("re-parse");
let names: Vec<&str> = reparsed
.channels()
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(
!names.contains(&"nonguix"),
"nonguix should be gone: {names:?}"
);
assert_eq!(reparsed.channels().len(), 2);
}
#[tokio::test]
async fn remove_channel_from_cons_star_defaults() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("cons-star-multi.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let target = cf.list.channels()[0].name.clone();
let new_src = cf
.apply(&repl, ChannelOp::RemoveChannelByName(target.clone()))
.await
.expect("apply remove");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
let reparsed = libguix::parse_channels_list(&new_src).expect("re-parse");
let names: Vec<&str> = reparsed
.channels()
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(!names.contains(&target.as_str()));
}
#[tokio::test]
async fn remove_only_channel_from_cons_single_collapses() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("cons-single.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let target = cf.list.channels()[0].name.clone();
let new_src = cf
.apply(&repl, ChannelOp::RemoveChannelByName(target))
.await
.expect("apply remove");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
assert!(
new_src.contains("%default-channels"),
"expected %default-channels in output: {new_src}"
);
}
#[tokio::test]
async fn remove_channel_returns_not_found_for_unknown_name() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let err = cf
.apply(
&repl,
ChannelOp::RemoveChannelByName("does-not-exist".into()),
)
.await
.expect_err("expected NotFound");
assert!(
matches!(err, ChannelsError::NotFound { ref name } if name == "does-not-exist"),
"wrong error: {err:?}"
);
}
#[tokio::test]
async fn remove_guix_channel_from_explicit_is_rejected() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let err = cf
.apply(&repl, ChannelOp::RemoveChannelByName("guix".into()))
.await
.expect_err("expected refusal");
assert!(
matches!(err, ChannelsError::UnsupportedOp { .. }),
"wrong error: {err:?}"
);
}
#[tokio::test]
async fn remove_channel_from_wrapped_and_commented_drops_inline_comment() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("wrapped-and-commented.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let new_src = cf
.apply(&repl, ChannelOp::RemoveChannelByName("nonguix".into()))
.await
.expect("apply remove");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
assert!(
!new_src.contains("gitlab.com/nonguix/nonguix"),
"nonguix alternate-URL comment should be gone:\n{new_src}"
);
assert!(
new_src.contains("codeberg.org/anemofilia/radix"),
"radix alternate-URL comment should survive:\n{new_src}"
);
assert!(
new_src.contains("codeberg.org/hako/rosenthal"),
"rosenthal alternate-URL comment should survive:\n{new_src}"
);
}
#[tokio::test]
async fn remove_wrapped_channel_drops_the_wrapper() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("wrapped-and-commented.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let err = cf
.apply(&repl, ChannelOp::RemoveChannelByName("guix".into()))
.await
.expect_err("removing guix is refused at pre-flight");
assert!(
matches!(err, ChannelsError::UnsupportedOp { .. }),
"wrong error: {err:?}"
);
let new_src = cf
.apply(&repl, ChannelOp::RemoveChannelByName("nonguix".into()))
.await
.expect("remove nonguix");
assert!(
new_src.contains("channel-with-substitutes-available"),
"wrapper around guix should survive removing nonguix"
);
assert!(
!new_src.contains("(channel-with-substitutes-available)"),
"no orphan empty wrapper expected:\n{new_src}"
);
}
#[tokio::test]
async fn add_channel_to_wrapped_and_commented_preserves_all_comments() {
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let (_dir, path) = copy_to_tempdir("wrapped-and-commented.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let new_ch = synthesised_channel();
let new_src = cf
.apply(&repl, ChannelOp::AddChannel(new_ch))
.await
.expect("apply add");
ChannelsFile::validate(&repl, &new_src)
.await
.expect("output validates");
for needle in [
"gitlab.com/debdistutils/guix/mirror.git",
"gitlab.com/nonguix/nonguix",
"git.sr.ht/~abcdw/rde",
"codeberg.org/anemofilia/radix.git",
"git.ajattix.org/hashirama/ajattix.git",
"codeberg.org/hako/rosenthal.git",
"gitlab.inria.fr/guix-hpc/guix-hpc.git",
"codeberg.org/fishinthecalculator/small-guix.git",
"gitlab.vulnix.sh/spacecadet/guix-xlibre.git",
"codeberg.org/look/saayix",
] {
assert!(
new_src.contains(needle),
"inline alternate-URL comment `{needle}` lost from output:\n{new_src}"
);
}
assert!(
new_src.contains("channel-with-substitutes-available"),
"wrapper survives add:\n{new_src}"
);
assert!(
new_src.contains("panther-test"),
"new channel missing:\n{new_src}"
);
}
#[tokio::test]
async fn write_atomic_creates_bak_and_renames() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let original = std::fs::read_to_string(&path).expect("read original");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let new_content = format!("{original}\n;; touched\n");
cf.write_atomic(&new_content).await.expect("write");
let after = std::fs::read_to_string(&path).expect("read after");
assert_eq!(after, new_content, "file content updated");
let bak = path.with_extension("scm.bak");
assert!(bak.exists(), ".bak should exist at {bak:?}");
let bak_content = std::fs::read_to_string(&bak).expect("read bak");
assert_eq!(bak_content, original, ".bak preserves prior content");
let tmp = path.with_extension("scm.tmp");
assert!(!tmp.exists(), ".tmp should have been renamed away");
}
#[cfg(unix)]
#[tokio::test]
async fn write_atomic_refuses_store_managed() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let real = dir.path().join("real.scm");
std::fs::copy(fixture("list-three.scm"), &real).expect("copy");
let link = dir.path().join("channels.scm");
let mut cf = ChannelsFile::read(Some(&real)).await.expect("read");
cf.is_store_managed = true;
symlink(
"/gnu/store/00000000000000000000000000000000-channels/channels.scm",
&link,
)
.expect("symlink");
cf.path = link;
let err = cf
.write_atomic("(list)\n")
.await
.expect_err("expected StoreManaged");
assert!(
matches!(err, ChannelsError::StoreManaged { .. }),
"wrong error: {err:?}"
);
}
#[tokio::test]
async fn add_channel_without_introduction_rejected_in_preflight() {
let (_dir, path) = copy_to_tempdir("list-three.scm");
let cf = ChannelsFile::read(Some(&path)).await.expect("read");
let no_intro = Channel {
name: "panther-test".into(),
url: "https://example/panther-test.git".into(),
branch: None,
commit: None,
introduction_commit: None,
introduction_fingerprint: None,
};
let Some(repl) = common::shared_repl_or_skip().await else {
return;
};
let err = cf
.apply(&repl, ChannelOp::AddChannel(no_intro))
.await
.expect_err("expected missing-introduction");
assert!(
matches!(err, ChannelsError::MissingIntroduction { .. }),
"wrong error: {err:?}"
);
}