1use std::{env, process::Command};
12
13use crate::model::ToolName;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Receipt {
18 pub manager: &'static str,
19 pub package: String,
20 pub version: String,
22}
23
24#[must_use]
28pub fn upstream_version(debian: &str) -> &str {
29 let without_epoch = debian.split_once(':').map_or(debian, |(_, rest)| rest);
30 without_epoch
31 .rsplit_once('-')
32 .map_or(without_epoch, |(upstream, _)| upstream)
33}
34
35#[must_use]
37pub fn primary_program(tool: ToolName) -> Option<String> {
38 match tool {
39 ToolName::Quipu => Some("quipu".into()),
40 ToolName::Bobbin => Some("bobbin".into()),
41 ToolName::Yupana => Some("yupana".into()),
42 ToolName::DesirePath => Some("dp".into()),
43 ToolName::Camayoc => None,
44 ToolName::Member(name) => {
45 crate::members::get(name).and_then(|m| m.programs.first().map(|p| p.name.clone()))
46 }
47 }
48}
49
50#[must_use]
52pub fn apt_receipt(program: &str) -> Option<Receipt> {
53 let path = crate::adapter::which_all(program, env::var_os("PATH").as_deref())
54 .into_iter()
55 .next()?;
56 let package = crate::apt_owned::owner(&path)?;
57 let query = env::var_os("CABOODLE_DPKG_QUERY").unwrap_or_else(|| "dpkg-query".into());
58 let out = Command::new(query)
59 .args(["-W", "-f", "${Version}", &package])
60 .output()
61 .ok()?;
62 if !out.status.success() {
63 return None;
64 }
65 let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
66 if raw.is_empty() {
67 return None;
68 }
69 Some(Receipt {
70 manager: "apt",
71 version: upstream_version(&raw).to_owned(),
72 package,
73 })
74}
75
76pub const STACK_TAP: &str = "scbrown/tap";
79
80#[must_use]
83pub fn brew_formula(tool: ToolName) -> Option<&'static str> {
84 match tool {
85 ToolName::Bobbin => Some("bobbin"),
86 ToolName::DesirePath => Some("desire-path"),
87 ToolName::Quipu => Some("quipu"),
88 ToolName::Yupana => Some("yupana"),
89 ToolName::Member("seeds") => Some("seeds"),
90 ToolName::Member("chaski") => Some("chaski"),
93 _ => None,
94 }
95}
96
97#[must_use]
100pub fn brew_program() -> Option<std::ffi::OsString> {
101 if let Some(explicit) = env::var_os("CABOODLE_BREW") {
102 return Some(explicit);
103 }
104 cfg!(target_os = "macos").then(|| "brew".into())
105}
106
107fn brew_info(brew: &std::ffi::OsStr, formula: &str) -> Option<String> {
115 let out = Command::new(brew)
116 .args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
117 .output()
118 .ok()?;
119 if !out.status.success() {
120 return None;
121 }
122 installed_version(&out.stdout, formula)
123}
124
125fn installed_version(json: &[u8], formula: &str) -> Option<String> {
129 let info: serde_json::Value = serde_json::from_slice(json).ok()?;
130 let wanted = format!("{STACK_TAP}/{formula}");
131 let entry =
132 info.get("formulae")?.as_array()?.iter().find(|entry| {
133 entry.get("full_name").and_then(|v| v.as_str()) == Some(wanted.as_str())
134 })?;
135 if entry.get("tap")?.as_str()? != STACK_TAP {
136 return None;
137 }
138 let version = entry
139 .get("installed")?
140 .as_array()?
141 .first()?
142 .get("version")?
143 .as_str()?;
144 Some(version.split('_').next()?.to_owned())
146}
147
148#[must_use]
150pub fn brew_receipt(tool: ToolName) -> Option<Receipt> {
151 let formula = brew_formula(tool)?;
152 let version = brew_info(&brew_program()?, formula)?;
153 Some(Receipt {
154 manager: "brew",
155 package: format!("{STACK_TAP}/{formula}"),
156 version,
157 })
158}
159
160#[must_use]
162pub fn brew_available(tool: ToolName) -> Option<String> {
163 let formula = brew_formula(tool)?;
164 let out = Command::new(brew_program()?)
165 .args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
166 .output()
167 .ok()?;
168 if !out.status.success() {
169 return None;
170 }
171 let info: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
172 let entry = info.get("formulae")?.as_array()?.first()?;
173 if entry.get("tap")?.as_str()? != STACK_TAP {
174 return None;
175 }
176 Some(entry.get("versions")?.get("stable")?.as_str()?.to_owned())
177}
178
179#[derive(Debug, PartialEq, Eq)]
181pub struct BrewChannel {
182 pub installed: Option<String>,
183 pub stable: String,
184 pub pinned: bool,
185}
186
187fn channel_from_json(bytes: &[u8], formula: &str) -> anyhow::Result<Option<BrewChannel>> {
188 use anyhow::Context;
189 let info: serde_json::Value = serde_json::from_slice(bytes).context("brew channel JSON")?;
190 let wanted = format!("{STACK_TAP}/{formula}");
191 let entries = info
192 .get("formulae")
193 .and_then(serde_json::Value::as_array)
194 .context("brew channel formula list")?;
195 let Some(entry) = entries.iter().find(|entry| {
196 entry.get("full_name").and_then(serde_json::Value::as_str) == Some(wanted.as_str())
197 && entry.get("tap").and_then(serde_json::Value::as_str) == Some(STACK_TAP)
198 }) else {
199 return Ok(None);
200 };
201 let stable = entry
202 .get("versions")
203 .and_then(|v| v.get("stable"))
204 .and_then(serde_json::Value::as_str)
205 .filter(|v| !v.is_empty())
206 .context("brew stable channel version")?
207 .to_owned();
208 let pinned = entry
209 .get("pinned")
210 .and_then(serde_json::Value::as_bool)
211 .context("brew formula pin status")?;
212 let installed = entry
213 .get("installed")
214 .and_then(serde_json::Value::as_array)
215 .context("brew installed receipt list")?
216 .first()
217 .map(|receipt| {
218 receipt
219 .get("version")
220 .and_then(serde_json::Value::as_str)
221 .filter(|v| !v.is_empty())
222 .map(|v| v.split('_').next().unwrap_or(v).to_owned())
223 .context("brew installed version")
224 })
225 .transpose()?;
226 Ok(Some(BrewChannel {
227 installed,
228 stable,
229 pinned,
230 }))
231}
232
233pub fn brew_refresh() -> anyhow::Result<()> {
236 let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
237 let status = Command::new(brew).args(["update", "--quiet"]).status()?;
238 if !status.success() {
239 anyhow::bail!("brew update failed ({status})");
240 }
241 Ok(())
242}
243
244pub fn brew_channel(tool: ToolName) -> anyhow::Result<Option<BrewChannel>> {
245 let Some(formula) = brew_formula(tool) else {
246 return Ok(None);
247 };
248 let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
249 let out = Command::new(brew)
250 .args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
251 .env("HOMEBREW_NO_AUTO_UPDATE", "1")
252 .output()?;
253 if !out.status.success() {
254 anyhow::bail!("brew channel read failed for {formula} ({})", out.status);
255 }
256 channel_from_json(&out.stdout, formula)
257}
258
259pub fn brew_converge(tool: ToolName, upgrade: bool) -> anyhow::Result<()> {
262 let formula = brew_formula(tool).ok_or_else(|| anyhow::anyhow!("no stack formula"))?;
263 let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
264 let verb = if upgrade { "upgrade" } else { "install" };
265 let status = Command::new(&brew)
266 .args([verb, &format!("{STACK_TAP}/{formula}")])
267 .env("HOMEBREW_NO_AUTO_UPDATE", "1")
268 .status()?;
269 if !status.success() {
270 anyhow::bail!("brew {verb} {STACK_TAP}/{formula} failed ({status})");
271 }
272 Ok(())
273}
274
275#[derive(Debug, PartialEq, Eq)]
277pub enum Standing {
278 Current,
279 Ahead,
280 Behind,
281 Unknown,
283}
284
285#[must_use]
286pub fn standing(receipt_version: &str, desired: &str) -> Standing {
287 let installed = format!("pkg {receipt_version}");
288 match (
289 crate::release_update::behind_reviewed(&installed, desired),
290 crate::release_update::behind_reviewed(desired, &installed),
291 ) {
292 (Some(true), _) => Standing::Behind,
293 (Some(false), Some(true)) => Standing::Ahead,
294 (Some(false), Some(false)) => Standing::Current,
295 _ => Standing::Unknown,
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::installed_version;
302
303 fn entry(full_name: &str, tap: &str, installed: &[&str]) -> serde_json::Value {
304 serde_json::json!({
305 "full_name": full_name,
306 "tap": tap,
307 "installed": installed.iter().map(|v| serde_json::json!({"version": v})).collect::<Vec<_>>(),
308 })
309 }
310
311 fn info(entries: Vec<serde_json::Value>) -> Vec<u8> {
312 serde_json::to_vec(&serde_json::json!({"formulae": entries, "casks": []})).unwrap()
313 }
314
315 #[test]
316 fn receipt_reads_the_named_member_not_the_first_installed_formula() {
317 let listing = info(vec![
319 entry("abseil", "homebrew/core", &["20250814.1"]),
320 entry("scbrown/tap/quipu", "scbrown/tap", &["0.14.1"]),
321 ]);
322 assert_eq!(
323 installed_version(&listing, "quipu").as_deref(),
324 Some("0.14.1")
325 );
326 assert_eq!(installed_version(&listing, "bobbin"), None);
327 }
328
329 #[test]
330 fn receipt_refuses_uninstalled_foreign_tap_and_malformed_answers() {
331 assert_eq!(
332 installed_version(
333 &info(vec![entry("scbrown/tap/bobbin", "scbrown/tap", &[])]),
334 "bobbin"
335 ),
336 None
337 );
338 assert_eq!(
339 installed_version(
340 &info(vec![entry("scbrown/tap/bobbin", "other/tap", &["1.0.0"])]),
341 "bobbin"
342 ),
343 None
344 );
345 assert_eq!(installed_version(b"not json", "bobbin"), None);
346 assert_eq!(
347 installed_version(
348 &info(vec![entry(
349 "scbrown/tap/yupana",
350 "scbrown/tap",
351 &["0.13.0_1"]
352 )]),
353 "yupana"
354 )
355 .as_deref(),
356 Some("0.13.0")
357 );
358 }
359
360 use super::*;
361
362 #[test]
363 fn debian_revision_and_epoch_are_not_part_of_the_pinned_version() {
364 assert_eq!(upstream_version("0.13.0"), "0.13.0");
365 assert_eq!(upstream_version("0.13.0-2"), "0.13.0");
366 assert_eq!(upstream_version("1:0.13.0-2"), "0.13.0");
367 assert_eq!(upstream_version("0.12.1-3"), "0.12.1");
368 }
369
370 #[test]
371 fn caboodle_self_and_untapped_tools_have_no_stack_formula() {
372 assert_eq!(brew_formula(ToolName::Bobbin), Some("bobbin"));
373 assert_eq!(brew_formula(ToolName::DesirePath), Some("desire-path"));
374 assert_eq!(brew_formula(ToolName::Member("seeds")), Some("seeds"));
375 assert_eq!(brew_formula(ToolName::Member("chaski")), Some("chaski"));
376 assert_eq!(brew_formula(ToolName::Camayoc), None);
377 assert_eq!(brew_formula(ToolName::Member("shuttle")), None);
378 }
379
380 #[test]
381 fn package_standing_never_reads_ahead_as_an_update() {
382 assert_eq!(standing("0.13.0", "quipu 0.13.0"), Standing::Current);
383 assert_eq!(standing("0.12.1", "quipu 0.13.0"), Standing::Behind);
384 assert_eq!(standing("0.28.0", "bobbin 0.27.3"), Standing::Ahead);
385 assert_eq!(standing("dev", "bobbin 0.27.3"), Standing::Unknown);
386 assert_eq!(standing("0.7.1", "dp v0.3.2 (1f457ec)"), Standing::Ahead);
387 }
388}
389
390#[cfg(test)]
391mod channel_tests {
392 use super::*;
393 use serde_json::json;
394
395 #[test]
396 fn stable_channel_selects_exact_stack_formula_and_retains_pin() {
397 let bytes=serde_json::to_vec(&json!({"formulae":[
398 {"full_name":"other/bobbin","tap":"other","versions":{"stable":"99.0.0"},"installed":[{"version":"99.0.0"}],"pinned":false},
399 {"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","versions":{"stable":"0.29.0"},"installed":[{"version":"0.28.0_2"}],"pinned":true}
400 ]})).unwrap();
401 assert_eq!(
402 channel_from_json(&bytes, "bobbin").unwrap(),
403 Some(BrewChannel {
404 installed: Some("0.28.0".into()),
405 stable: "0.29.0".into(),
406 pinned: true
407 })
408 );
409 assert_eq!(channel_from_json(&bytes, "quipu").unwrap(), None);
410 }
411
412 #[test]
413 fn malformed_channel_cannot_stand_in_for_a_receipt() {
414 for raw in [
415 r#"{"formulae":[{"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","versions":{"stable":"0.29.0"},"installed":[] }]}"#,
416 r#"{"formulae":[{"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","pinned":false,"installed":[] }]}"#,
417 r#"{"formulae":[{"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","pinned":false,"versions":{"stable":"0.29.0"},"installed":[{}]}]}"#,
418 r#"{"formulae":null}"#,
419 "not JSON",
420 ] {
421 assert!(
422 channel_from_json(raw.as_bytes(), "bobbin").is_err(),
423 "{raw}"
424 );
425 }
426 }
427}