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