1use anyhow::Result;
2use console::style;
3
4use crate::exec;
5
6#[derive(Debug, Clone, PartialEq)]
8pub enum Source {
9 Nix,
10 BrewCask,
11 BrewFormula,
12}
13
14impl std::fmt::Display for Source {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Source::Nix => write!(f, "nixpkgs"),
18 Source::BrewCask => write!(f, "brew cask"),
19 Source::BrewFormula => write!(f, "brew formula"),
20 }
21 }
22}
23
24#[derive(Debug, Clone)]
26pub struct Candidate {
27 pub source: Source,
28 pub version: String,
29}
30
31pub enum Resolution {
33 Single(Candidate),
35 Conflict {
37 candidates: Vec<Candidate>,
38 recommended: Source,
39 reason: String,
40 versions_equal: bool,
42 },
43 NotFound,
45}
46
47pub struct ResolveResult {
49 pub resolution: Resolution,
50 pub brew_checked: bool,
52}
53
54pub fn resolve_with_sources(pkg: &str, sources: &[Source]) -> Result<ResolveResult> {
56 tracing::debug!(%pkg, ?sources, "resolving package");
57 let mut candidates = Vec::new();
58 let wants_nix = sources.contains(&Source::Nix);
59 let wants_brew_cask = sources.contains(&Source::BrewCask);
60 let wants_brew_formula = sources.contains(&Source::BrewFormula);
61 let wants_brew = wants_brew_cask || wants_brew_formula;
62 let brew_checked = wants_brew && exec::brew_available();
63
64 if wants_nix {
65 let nix_attr = crate::aliases::nixpkgs_attr(pkg);
67 let nix_version = exec::nix_eval_version(nix_attr)?.or(if nix_attr != pkg {
68 exec::nix_eval_version(pkg)?
69 } else {
70 None
71 });
72 if let Some(version) = nix_version {
73 tracing::debug!(%pkg, version = %version, "found in nixpkgs");
74 candidates.push(Candidate {
75 source: Source::Nix,
76 version,
77 });
78 }
79 }
80
81 if brew_checked {
82 if wants_brew_cask {
84 let cask_version =
85 exec::brew_cask_info(pkg)?.or(match crate::aliases::brew_cask_name(pkg) {
86 Some(cask_name) if cask_name != pkg => exec::brew_cask_info(cask_name)?,
87 _ => None,
88 });
89 if let Some(version) = cask_version {
90 tracing::debug!(%pkg, version = %version, "found as brew cask");
91 candidates.push(Candidate {
92 source: Source::BrewCask,
93 version,
94 });
95 }
96 }
97
98 if wants_brew_formula && !candidates.iter().any(|c| c.source == Source::BrewCask) {
100 if let Some(version) = exec::brew_formula_info(pkg)? {
101 tracing::debug!(%pkg, version = %version, "found as brew formula");
102 candidates.push(Candidate {
103 source: Source::BrewFormula,
104 version,
105 });
106 }
107 }
108 }
109
110 let resolution = match candidates.len() {
111 0 => Resolution::NotFound,
112 1 => {
113 #[allow(clippy::unwrap_used)]
115 let candidate = candidates.into_iter().next().unwrap();
116 Resolution::Single(candidate)
117 }
118 _ => {
119 let (recommended, reason, versions_equal) = recommend(&candidates);
120 Resolution::Conflict {
121 candidates,
122 recommended,
123 reason,
124 versions_equal,
125 }
126 }
127 };
128
129 Ok(ResolveResult {
130 resolution,
131 brew_checked,
132 })
133}
134
135fn versions_equal(a: &str, b: &str) -> bool {
138 let result = if a == b {
139 true
140 } else if let (Ok(va), Ok(vb)) = (
141 semver::Version::parse(a).or_else(|_| lenient_semver_parse(a)),
142 semver::Version::parse(b).or_else(|_| lenient_semver_parse(b)),
143 ) {
144 va == vb
146 } else {
147 false
148 };
149 tracing::trace!(%a, %b, equal = %result, "version comparison");
150 result
151}
152
153fn lenient_semver_parse(s: &str) -> Result<semver::Version, semver::Error> {
155 let parts: Vec<&str> = s.split('.').collect();
156 match parts.len() {
157 1 => semver::Version::parse(&format!("{}.0.0", s)),
158 2 => semver::Version::parse(&format!("{}.0", s)),
159 _ => semver::Version::parse(s),
160 }
161}
162
163fn recommend(candidates: &[Candidate]) -> (Source, String, bool) {
166 let nix = candidates.iter().find(|c| c.source == Source::Nix);
167 let brew = candidates
168 .iter()
169 .find(|c| c.source == Source::BrewCask || c.source == Source::BrewFormula);
170
171 tracing::debug!(nix_ver = ?nix.map(|n| &n.version), brew_ver = ?brew.map(|b| &b.version), "comparing versions");
172
173 if let (Some(n), Some(b)) = (nix, brew) {
174 let eq = versions_equal(&n.version, &b.version);
175
176 if b.source == Source::BrewCask && package_has_cask_redirect(&n.version) {
177 return (
178 Source::BrewCask,
179 "nix package redirects to the Homebrew cask".into(),
180 eq,
181 );
182 }
183
184 if eq {
185 return (
187 Source::Nix,
188 "same version in both — nix provides declarative management".into(),
189 true,
190 );
191 }
192
193 let brew_label = if b.source == Source::BrewCask {
195 "cask"
196 } else {
197 "formula"
198 };
199 return (
200 b.source.clone(),
201 format!("brew {brew_label} is {} (nix has {})", b.version, n.version),
202 false,
203 );
204 }
205
206 (
208 Source::Nix,
209 "nix provides declarative management".into(),
210 false,
211 )
212}
213
214fn package_has_cask_redirect(version: &str) -> bool {
215 let lower = version.to_ascii_lowercase();
216 lower.contains("use brew cask") || lower.contains("homebrew cask")
217}
218
219pub struct PromptResult {
221 pub source: Source,
222 pub remember_nix: bool,
224}
225
226pub fn prompt_resolution(
230 pkg: &str,
231 candidates: &[Candidate],
232 recommended: &Source,
233 reason: &str,
234 versions_equal: bool,
235) -> Result<Option<PromptResult>> {
236 eprintln!();
237 eprintln!(" {} found in multiple sources:", style(pkg).bold());
238 eprintln!();
239
240 for c in candidates {
241 let marker = if c.source == *recommended {
242 style("*").green().bold().to_string()
243 } else {
244 " ".to_string()
245 };
246 let source_label = format!("{:<14}", c.source.to_string());
247 eprintln!(
248 " {} {} {}",
249 marker,
250 style(source_label).cyan(),
251 c.version
252 );
253 }
254
255 eprintln!();
256 eprintln!(" {} {}", style("recommended:").dim(), reason);
257 eprintln!();
258
259 let mut items: Vec<String> = candidates
261 .iter()
262 .map(|c| {
263 let rec = if c.source == *recommended {
264 " (recommended)"
265 } else {
266 ""
267 };
268 format!("{} {}{}", c.source, c.version, rec)
269 })
270 .collect();
271
272 if versions_equal {
274 items.push("Always use nix when versions match".into());
275 }
276
277 let default_idx = candidates
278 .iter()
279 .position(|c| c.source == *recommended)
280 .unwrap_or(0);
281
282 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
284 return Ok(Some(PromptResult {
285 source: recommended.clone(),
286 remember_nix: false,
287 }));
288 }
289
290 let items_str: Vec<String> = items.iter().map(|s| s.to_string()).collect();
291 let selection = crate::input::input()
292 .select("Install as", &items_str, default_idx)
293 .ok();
294
295 match selection {
296 Some(idx) if idx < candidates.len() => Ok(Some(PromptResult {
297 source: candidates[idx].source.clone(),
298 remember_nix: false,
299 })),
300 Some(_) => {
301 Ok(Some(PromptResult {
303 source: Source::Nix,
304 remember_nix: true,
305 }))
306 }
307 None => Ok(None),
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::{recommend, Candidate, Source};
314
315 #[test]
316 fn recommends_brew_cask_when_nix_version_is_cask_redirect_message() {
317 let candidates = vec![
318 Candidate {
319 source: Source::Nix,
320 version: "Please use brew cask to install kitty".to_string(),
321 },
322 Candidate {
323 source: Source::BrewCask,
324 version: "Run `brew install --cask kitty`".to_string(),
325 },
326 ];
327
328 let (source, reason, versions_equal) = recommend(&candidates);
329
330 assert_eq!(source, Source::BrewCask);
331 assert!(reason.contains("brew cask"));
332 assert!(!versions_equal);
333 }
334}