use std::{env, process::Command};
use crate::model::ToolName;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Receipt {
pub manager: &'static str,
pub package: String,
pub version: String,
}
#[must_use]
pub fn upstream_version(debian: &str) -> &str {
let without_epoch = debian.split_once(':').map_or(debian, |(_, rest)| rest);
without_epoch
.rsplit_once('-')
.map_or(without_epoch, |(upstream, _)| upstream)
}
#[must_use]
pub fn primary_program(tool: ToolName) -> Option<String> {
match tool {
ToolName::Quipu => Some("quipu".into()),
ToolName::Bobbin => Some("bobbin".into()),
ToolName::Yupana => Some("yupana".into()),
ToolName::DesirePath => Some("dp".into()),
ToolName::Camayoc => None,
ToolName::Member(name) => {
crate::members::get(name).and_then(|m| m.programs.first().map(|p| p.name.clone()))
}
}
}
#[must_use]
pub fn apt_receipt(program: &str) -> Option<Receipt> {
let path = crate::adapter::which_all(program, env::var_os("PATH").as_deref())
.into_iter()
.next()?;
let package = crate::apt_owned::owner(&path)?;
let query = env::var_os("CABOODLE_DPKG_QUERY").unwrap_or_else(|| "dpkg-query".into());
let out = Command::new(query)
.args(["-W", "-f", "${Version}", &package])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
if raw.is_empty() {
return None;
}
Some(Receipt {
manager: "apt",
version: upstream_version(&raw).to_owned(),
package,
})
}
pub const STACK_TAP: &str = "scbrown/tap";
#[must_use]
pub fn brew_formula(tool: ToolName) -> Option<&'static str> {
match tool {
ToolName::Bobbin => Some("bobbin"),
ToolName::DesirePath => Some("desire-path"),
ToolName::Quipu => Some("quipu"),
ToolName::Yupana => Some("yupana"),
ToolName::Member("seeds") => Some("seeds"),
ToolName::Member("chaski") => Some("chaski"),
_ => None,
}
}
#[must_use]
pub fn brew_program() -> Option<std::ffi::OsString> {
if let Some(explicit) = env::var_os("CABOODLE_BREW") {
return Some(explicit);
}
cfg!(target_os = "macos").then(|| "brew".into())
}
fn brew_info(brew: &std::ffi::OsStr, formula: &str) -> Option<String> {
let out = Command::new(brew)
.args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
.output()
.ok()?;
if !out.status.success() {
return None;
}
installed_version(&out.stdout, formula)
}
fn installed_version(json: &[u8], formula: &str) -> Option<String> {
let info: serde_json::Value = serde_json::from_slice(json).ok()?;
let wanted = format!("{STACK_TAP}/{formula}");
let entry =
info.get("formulae")?.as_array()?.iter().find(|entry| {
entry.get("full_name").and_then(|v| v.as_str()) == Some(wanted.as_str())
})?;
if entry.get("tap")?.as_str()? != STACK_TAP {
return None;
}
let version = entry
.get("installed")?
.as_array()?
.first()?
.get("version")?
.as_str()?;
Some(version.split('_').next()?.to_owned())
}
#[must_use]
pub fn brew_receipt(tool: ToolName) -> Option<Receipt> {
let formula = brew_formula(tool)?;
let version = brew_info(&brew_program()?, formula)?;
Some(Receipt {
manager: "brew",
package: format!("{STACK_TAP}/{formula}"),
version,
})
}
#[must_use]
pub fn brew_available(tool: ToolName) -> Option<String> {
let formula = brew_formula(tool)?;
let out = Command::new(brew_program()?)
.args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let info: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let entry = info.get("formulae")?.as_array()?.first()?;
if entry.get("tap")?.as_str()? != STACK_TAP {
return None;
}
Some(entry.get("versions")?.get("stable")?.as_str()?.to_owned())
}
pub fn brew_converge(tool: ToolName, upgrade: bool) -> anyhow::Result<()> {
let formula = brew_formula(tool).ok_or_else(|| anyhow::anyhow!("no stack formula"))?;
let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
let verb = if upgrade { "upgrade" } else { "install" };
let status = Command::new(&brew)
.args([verb, &format!("{STACK_TAP}/{formula}")])
.status()?;
if !status.success() {
anyhow::bail!("brew {verb} {STACK_TAP}/{formula} failed ({status})");
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum Standing {
Current,
Ahead,
Behind,
Unknown,
}
#[must_use]
pub fn standing(receipt_version: &str, desired: &str) -> Standing {
let installed = format!("pkg {receipt_version}");
match (
crate::release_update::behind_reviewed(&installed, desired),
crate::release_update::behind_reviewed(desired, &installed),
) {
(Some(true), _) => Standing::Behind,
(Some(false), Some(true)) => Standing::Ahead,
(Some(false), Some(false)) => Standing::Current,
_ => Standing::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::installed_version;
fn entry(full_name: &str, tap: &str, installed: &[&str]) -> serde_json::Value {
serde_json::json!({
"full_name": full_name,
"tap": tap,
"installed": installed.iter().map(|v| serde_json::json!({"version": v})).collect::<Vec<_>>(),
})
}
fn info(entries: Vec<serde_json::Value>) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({"formulae": entries, "casks": []})).unwrap()
}
#[test]
fn receipt_reads_the_named_member_not_the_first_installed_formula() {
let listing = info(vec![
entry("abseil", "homebrew/core", &["20250814.1"]),
entry("scbrown/tap/quipu", "scbrown/tap", &["0.14.1"]),
]);
assert_eq!(
installed_version(&listing, "quipu").as_deref(),
Some("0.14.1")
);
assert_eq!(installed_version(&listing, "bobbin"), None);
}
#[test]
fn receipt_refuses_uninstalled_foreign_tap_and_malformed_answers() {
assert_eq!(
installed_version(
&info(vec![entry("scbrown/tap/bobbin", "scbrown/tap", &[])]),
"bobbin"
),
None
);
assert_eq!(
installed_version(
&info(vec![entry("scbrown/tap/bobbin", "other/tap", &["1.0.0"])]),
"bobbin"
),
None
);
assert_eq!(installed_version(b"not json", "bobbin"), None);
assert_eq!(
installed_version(
&info(vec![entry(
"scbrown/tap/yupana",
"scbrown/tap",
&["0.13.0_1"]
)]),
"yupana"
)
.as_deref(),
Some("0.13.0")
);
}
use super::*;
#[test]
fn debian_revision_and_epoch_are_not_part_of_the_pinned_version() {
assert_eq!(upstream_version("0.13.0"), "0.13.0");
assert_eq!(upstream_version("0.13.0-2"), "0.13.0");
assert_eq!(upstream_version("1:0.13.0-2"), "0.13.0");
assert_eq!(upstream_version("0.12.1-3"), "0.12.1");
}
#[test]
fn caboodle_self_and_untapped_tools_have_no_stack_formula() {
assert_eq!(brew_formula(ToolName::Bobbin), Some("bobbin"));
assert_eq!(brew_formula(ToolName::DesirePath), Some("desire-path"));
assert_eq!(brew_formula(ToolName::Member("seeds")), Some("seeds"));
assert_eq!(brew_formula(ToolName::Member("chaski")), Some("chaski"));
assert_eq!(brew_formula(ToolName::Camayoc), None);
assert_eq!(brew_formula(ToolName::Member("shuttle")), None);
}
#[test]
fn package_standing_never_reads_ahead_as_an_update() {
assert_eq!(standing("0.13.0", "quipu 0.13.0"), Standing::Current);
assert_eq!(standing("0.12.1", "quipu 0.13.0"), Standing::Behind);
assert_eq!(standing("0.28.0", "bobbin 0.27.3"), Standing::Ahead);
assert_eq!(standing("dev", "bobbin 0.27.3"), Standing::Unknown);
assert_eq!(standing("0.7.1", "dp v0.3.2 (1f457ec)"), Standing::Ahead);
}
}