Skip to main content

nex_pkg/
resolve.rs

1use anyhow::Result;
2use console::style;
3
4use crate::exec;
5
6/// Where a package was found.
7#[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/// A package found in a particular source with its version.
25#[derive(Debug, Clone)]
26pub struct Candidate {
27    pub source: Source,
28    pub version: String,
29}
30
31/// The resolution outcome.
32pub enum Resolution {
33    /// Only one source has it — use that.
34    Single(Candidate),
35    /// Multiple sources — nex recommends one but the user should confirm.
36    Conflict {
37        candidates: Vec<Candidate>,
38        recommended: Source,
39        reason: String,
40        /// True when nix and brew have the same version (eligible for auto-preference).
41        versions_equal: bool,
42    },
43    /// Not found anywhere.
44    NotFound,
45}
46
47/// Full result of resolve(), including whether all sources were checked.
48pub struct ResolveResult {
49    pub resolution: Resolution,
50    /// True if brew was available and checked during resolution.
51    pub brew_checked: bool,
52}
53
54/// Resolve a package using only the enabled sources for the active provider policy.
55pub 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        // Check nixpkgs — try the canonical alias first, then the raw name
66        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        // Check brew cask — try the raw name, then known cask aliases
83        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        // Check brew formula (only if not found as cask — formulae and casks rarely overlap)
99        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            // Safe: len checked above
114            #[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
135/// Compare two version strings with semantic version awareness.
136/// Falls back to string equality when both versions fail to parse as semver.
137fn 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        // Try lenient semver parse (handles versions like "1.0" -> "1.0.0")
145        va == vb
146    } else {
147        false
148    };
149    tracing::trace!(%a, %b, equal = %result, "version comparison");
150    result
151}
152
153/// Try to parse a version string that may be missing patch/minor components.
154fn 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
163/// Decide which source to recommend when there's a conflict.
164/// Returns (recommended_source, reason, versions_equal).
165fn 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            // Same version — recommend nix (declarative, reproducible)
186            return (
187                Source::Nix,
188                "same version in both — nix provides declarative management".into(),
189                true,
190            );
191        }
192
193        // Brew has a different (likely newer) version — recommend brew
194        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    // Fallback: prefer nix
207    (
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
219/// Result of an interactive resolution prompt.
220pub struct PromptResult {
221    pub source: Source,
222    /// If true, the user wants to always pick nix for equal-version conflicts.
223    pub remember_nix: bool,
224}
225
226/// Display the resolution to the user and return the chosen source.
227/// Returns None if the user cancels.
228/// When `versions_equal` is true, offers an "always use nix" option.
229pub 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    // Build selection items
260    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    // When versions match, offer an "always nix" option
273    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    // When not interactive, use the recommended default
283    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            // "Always use nix" option selected
302            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}