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
179pub fn brew_converge(tool: ToolName, upgrade: bool) -> anyhow::Result<()> {
182 let formula = brew_formula(tool).ok_or_else(|| anyhow::anyhow!("no stack formula"))?;
183 let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
184 let verb = if upgrade { "upgrade" } else { "install" };
185 let status = Command::new(&brew)
186 .args([verb, &format!("{STACK_TAP}/{formula}")])
187 .status()?;
188 if !status.success() {
189 anyhow::bail!("brew {verb} {STACK_TAP}/{formula} failed ({status})");
190 }
191 Ok(())
192}
193
194#[derive(Debug, PartialEq, Eq)]
196pub enum Standing {
197 Current,
198 Ahead,
199 Behind,
200 Unknown,
202}
203
204#[must_use]
205pub fn standing(receipt_version: &str, desired: &str) -> Standing {
206 let installed = format!("pkg {receipt_version}");
207 match (
208 crate::release_update::behind_reviewed(&installed, desired),
209 crate::release_update::behind_reviewed(desired, &installed),
210 ) {
211 (Some(true), _) => Standing::Behind,
212 (Some(false), Some(true)) => Standing::Ahead,
213 (Some(false), Some(false)) => Standing::Current,
214 _ => Standing::Unknown,
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::installed_version;
221
222 fn entry(full_name: &str, tap: &str, installed: &[&str]) -> serde_json::Value {
223 serde_json::json!({
224 "full_name": full_name,
225 "tap": tap,
226 "installed": installed.iter().map(|v| serde_json::json!({"version": v})).collect::<Vec<_>>(),
227 })
228 }
229
230 fn info(entries: Vec<serde_json::Value>) -> Vec<u8> {
231 serde_json::to_vec(&serde_json::json!({"formulae": entries, "casks": []})).unwrap()
232 }
233
234 #[test]
235 fn receipt_reads_the_named_member_not_the_first_installed_formula() {
236 let listing = info(vec![
238 entry("abseil", "homebrew/core", &["20250814.1"]),
239 entry("scbrown/tap/quipu", "scbrown/tap", &["0.14.1"]),
240 ]);
241 assert_eq!(
242 installed_version(&listing, "quipu").as_deref(),
243 Some("0.14.1")
244 );
245 assert_eq!(installed_version(&listing, "bobbin"), None);
246 }
247
248 #[test]
249 fn receipt_refuses_uninstalled_foreign_tap_and_malformed_answers() {
250 assert_eq!(
251 installed_version(
252 &info(vec![entry("scbrown/tap/bobbin", "scbrown/tap", &[])]),
253 "bobbin"
254 ),
255 None
256 );
257 assert_eq!(
258 installed_version(
259 &info(vec![entry("scbrown/tap/bobbin", "other/tap", &["1.0.0"])]),
260 "bobbin"
261 ),
262 None
263 );
264 assert_eq!(installed_version(b"not json", "bobbin"), None);
265 assert_eq!(
266 installed_version(
267 &info(vec![entry(
268 "scbrown/tap/yupana",
269 "scbrown/tap",
270 &["0.13.0_1"]
271 )]),
272 "yupana"
273 )
274 .as_deref(),
275 Some("0.13.0")
276 );
277 }
278
279 use super::*;
280
281 #[test]
282 fn debian_revision_and_epoch_are_not_part_of_the_pinned_version() {
283 assert_eq!(upstream_version("0.13.0"), "0.13.0");
284 assert_eq!(upstream_version("0.13.0-2"), "0.13.0");
285 assert_eq!(upstream_version("1:0.13.0-2"), "0.13.0");
286 assert_eq!(upstream_version("0.12.1-3"), "0.12.1");
287 }
288
289 #[test]
290 fn caboodle_self_and_untapped_tools_have_no_stack_formula() {
291 assert_eq!(brew_formula(ToolName::Bobbin), Some("bobbin"));
292 assert_eq!(brew_formula(ToolName::DesirePath), Some("desire-path"));
293 assert_eq!(brew_formula(ToolName::Member("seeds")), Some("seeds"));
294 assert_eq!(brew_formula(ToolName::Member("chaski")), Some("chaski"));
295 assert_eq!(brew_formula(ToolName::Camayoc), None);
296 assert_eq!(brew_formula(ToolName::Member("shuttle")), None);
297 }
298
299 #[test]
300 fn package_standing_never_reads_ahead_as_an_update() {
301 assert_eq!(standing("0.13.0", "quipu 0.13.0"), Standing::Current);
302 assert_eq!(standing("0.12.1", "quipu 0.13.0"), Standing::Behind);
303 assert_eq!(standing("0.28.0", "bobbin 0.27.3"), Standing::Ahead);
304 assert_eq!(standing("dev", "bobbin 0.27.3"), Standing::Unknown);
305 assert_eq!(standing("0.7.1", "dp v0.3.2 (1f457ec)"), Standing::Ahead);
306 }
307}