use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::GuixError;
use crate::types::InstalledPackage;
use crate::Guix;
pub const UNKNOWN_BUCKET: &str = "(unknown)";
#[derive(Clone)]
pub struct InstalledOps {
guix: Guix,
}
impl InstalledOps {
pub(crate) fn new(guix: Guix) -> Self {
Self { guix }
}
pub async fn by_channel(&self) -> Result<HashMap<String, Vec<InstalledPackage>>, GuixError> {
let repl = self.guix.repl().await?;
let profile = resolve_profile_path();
let profile_str = profile.to_string_lossy().into_owned();
let escaped = scheme_string(&profile_str);
let form = format!("(libguix-rs:installed-with-locations {escaped})");
let value = repl.eval_persistent(&form).await?;
let entries = interpret_response(value)?;
Ok(bucket_entries(entries))
}
}
fn interpret_response(value: lexpr::Value) -> Result<Vec<InstalledEntry>, GuixError> {
let mut it = value
.list_iter()
.ok_or_else(|| GuixError::Parse("installed-with-locations: not a list".into()))?;
let head = it.next().and_then(lexpr::Value::as_symbol).ok_or_else(|| {
GuixError::Parse("installed-with-locations: missing response head".into())
})?;
match head {
"ok" => {
let payload = it.next().ok_or_else(|| {
GuixError::Parse("installed-with-locations: ok response missing payload".into())
})?;
Ok(parse_entries(payload.clone()))
}
"error" => {
let msg = it
.next()
.and_then(scheme_string_value)
.unwrap_or_else(|| "<no message>".into());
Err(GuixError::Parse(format!(
"installed-with-locations failed: {msg}"
)))
}
other => Err(GuixError::Parse(format!(
"installed-with-locations: unexpected head `{other}`"
))),
}
}
fn resolve_profile_path() -> PathBuf {
if let Some(p) = std::env::var_os("GUIX_PROFILE") {
return PathBuf::from(p);
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
home.join(".guix-profile")
}
#[derive(Debug, PartialEq, Eq)]
struct InstalledEntry {
name: String,
version: String,
source_file: String,
channels: Vec<String>,
}
fn parse_entries(value: lexpr::Value) -> Vec<InstalledEntry> {
let mut out = Vec::new();
let Some(iter) = value.list_iter() else {
return out;
};
for entry in iter {
let Some(fields) = entry.list_iter() else {
continue;
};
let mut name = String::new();
let mut version = String::new();
let mut source_file = String::new();
let mut channels: Vec<String> = Vec::new();
for (i, field) in fields.enumerate() {
match i {
0 => name = scheme_string_value(field).unwrap_or_default(),
1 => version = scheme_string_value(field).unwrap_or_default(),
2 => source_file = scheme_string_value(field).unwrap_or_default(),
3 => {
if let Some(list) = field.list_iter() {
for ch in list {
if let Some(s) = scheme_string_value(ch) {
if !s.is_empty() {
channels.push(s);
}
}
}
}
}
_ => break,
}
}
if !name.is_empty() {
out.push(InstalledEntry {
name,
version,
source_file,
channels,
});
}
}
out
}
fn scheme_string_value(v: &lexpr::Value) -> Option<String> {
match v {
lexpr::Value::String(s) | lexpr::Value::Symbol(s) => Some(s.to_string()),
_ => None,
}
}
fn bucket_entries(entries: Vec<InstalledEntry>) -> HashMap<String, Vec<InstalledPackage>> {
let mut buckets: HashMap<String, Vec<InstalledPackage>> = HashMap::new();
for e in entries {
let bucket = e
.channels
.first()
.cloned()
.unwrap_or_else(|| UNKNOWN_BUCKET.to_owned());
buckets.entry(bucket).or_default().push(InstalledPackage {
name: e.name,
version: e.version,
output: "out".into(),
store_path: PathBuf::from(e.source_file),
});
}
buckets
}
fn scheme_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
_ => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use lexpr::from_str;
#[test]
fn parses_and_buckets_typical_response() {
let v = from_str(
r#"(("hello" "2.12" "/path/to/foo.scm" ("guix"))
("panther-foo" "0.4" "/path/to/bar.scm" ("pantherx")))"#,
)
.unwrap();
let entries = parse_entries(v);
assert_eq!(entries.len(), 2);
let buckets = bucket_entries(entries);
assert_eq!(buckets.len(), 2);
let guix = buckets.get("guix").expect("guix bucket present");
assert_eq!(guix.len(), 1);
assert_eq!(guix[0].name, "hello");
assert_eq!(guix[0].version, "2.12");
let px = buckets.get("pantherx").expect("pantherx bucket present");
assert_eq!(px.len(), 1);
assert_eq!(px[0].name, "panther-foo");
}
#[test]
fn empty_channel_list_buckets_to_unknown() {
let v = from_str(r#"(("orphan" "1.0" "" ()))"#).unwrap();
let entries = parse_entries(v);
assert_eq!(entries.len(), 1);
assert!(entries[0].channels.is_empty());
let buckets = bucket_entries(entries);
let unknown = buckets.get(UNKNOWN_BUCKET).expect("unknown bucket present");
assert_eq!(unknown.len(), 1);
assert_eq!(unknown[0].name, "orphan");
}
#[test]
fn multi_channel_picks_first() {
let v = from_str(r#"(("shared" "1.0" "/x.scm" ("nonguix" "guix")))"#).unwrap();
let buckets = bucket_entries(parse_entries(v));
assert!(buckets.contains_key("nonguix"));
assert!(!buckets.contains_key("guix"));
}
#[test]
fn accepts_symbol_channel_names() {
let v = from_str(r#"(("hello" "2.12" "/x.scm" (guix)))"#).unwrap();
let buckets = bucket_entries(parse_entries(v));
assert!(buckets.contains_key("guix"));
}
#[test]
fn empty_response_yields_empty_map() {
let v = from_str("()").unwrap();
let buckets = bucket_entries(parse_entries(v));
assert!(buckets.is_empty());
}
}