Skip to main content

tatara_pkgs/
overlay.rs

1//! `OverlayPackageSet` — one set composed over another. `top` wins where it
2//! defines a package; `base` fills in the rest. Names enumerate the union.
3
4use crate::set::{PackageLookup, PackageSet, PackageSetError};
5
6pub struct OverlayPackageSet {
7    pub base: Box<dyn PackageSet>,
8    pub top: Box<dyn PackageSet>,
9    pub label: String,
10}
11
12impl OverlayPackageSet {
13    pub fn new(base: Box<dyn PackageSet>, top: Box<dyn PackageSet>) -> Self {
14        let lbl = format!("{} <- {}", base.label(), top.label());
15        Self {
16            base,
17            top,
18            label: lbl,
19        }
20    }
21}
22
23impl PackageSet for OverlayPackageSet {
24    fn get(&self, name: &str) -> Result<PackageLookup, PackageSetError> {
25        match self.top.get(name)? {
26            Some(d) => Ok(Some(d)),
27            None => self.base.get(name),
28        }
29    }
30
31    fn names(&self) -> Vec<String> {
32        let mut all = self.base.names();
33        for n in self.top.names() {
34            if !all.contains(&n) {
35                all.push(n);
36            }
37        }
38        all.sort();
39        all
40    }
41
42    fn label(&self) -> &str {
43        &self.label
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use crate::NixpkgsBridge;
51
52    /// A stub PackageSet that errors on every lookup — used to test
53    /// that errors from either side (top or base) propagate.
54    struct Erroring {
55        reason: String,
56    }
57    impl PackageSet for Erroring {
58        fn get(&self, _name: &str) -> Result<PackageLookup, PackageSetError> {
59            Err(PackageSetError::Backend(self.reason.clone()))
60        }
61        fn names(&self) -> Vec<String> {
62            vec![]
63        }
64        fn label(&self) -> &str {
65            &self.reason
66        }
67    }
68
69    /// A stub PackageSet that returns None for every lookup — lets us
70    /// build a genuinely empty overlay (NixpkgsBridge treats an empty
71    /// `with_names` vector as an OPEN universe and still resolves
72    /// anything, so it can't stand in for an empty closed set).
73    struct Empty {
74        label: String,
75    }
76    impl PackageSet for Empty {
77        fn get(&self, _name: &str) -> Result<PackageLookup, PackageSetError> {
78            Ok(None)
79        }
80        fn names(&self) -> Vec<String> {
81            vec![]
82        }
83        fn label(&self) -> &str {
84            &self.label
85        }
86    }
87
88    #[test]
89    fn top_wins_on_overlap_base_fills_rest() {
90        let base = NixpkgsBridge::new()
91            .with_names(vec!["hello".into(), "bash".into()])
92            .with_label("base");
93        let top = NixpkgsBridge::new()
94            .with_pkg_set("import ./my-nixpkgs.nix {}")
95            .with_names(vec!["bash".into(), "zsh".into()])
96            .with_label("top");
97        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
98
99        let bash = overlay.get("bash").unwrap().unwrap();
100        // bash comes from top, so its pkg_set is the overlay expr
101        assert_eq!(
102            bash.bridge.unwrap().pkg_set.as_deref(),
103            Some("import ./my-nixpkgs.nix {}")
104        );
105
106        let hello = overlay.get("hello").unwrap().unwrap();
107        // hello comes from base (default pkg_set → None → "import <nixpkgs> {}")
108        assert!(hello.bridge.unwrap().pkg_set.is_none());
109
110        let mut ns = overlay.names();
111        ns.sort();
112        assert_eq!(ns, vec!["bash", "hello", "zsh"]);
113    }
114
115    #[test]
116    fn label_composition_is_base_arrow_top() {
117        // Pinned: the composed label is "{base} <- {top}", matching
118        // the overlay direction in code. Log lines depend on this
119        // exact format.
120        let base = NixpkgsBridge::new().with_label("stable").with_names(vec![]);
121        let top = NixpkgsBridge::new().with_label("nightly").with_names(vec![]);
122        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
123        assert_eq!(overlay.label(), "stable <- nightly");
124    }
125
126    #[test]
127    fn empty_overlay_yields_no_names_and_none_lookup() {
128        // Genuinely empty closed universes on both sides. Lookup must
129        // return None (not Err), and names() must be empty.
130        let base = Empty { label: "b".into() };
131        let top = Empty { label: "t".into() };
132        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
133        assert!(overlay.get("anything").unwrap().is_none());
134        assert!(overlay.names().is_empty());
135    }
136
137    #[test]
138    fn names_are_returned_sorted() {
139        // Docstring says "Names enumerate the union"; the impl sorts
140        // after merging. A future refactor that drops the sort call
141        // would produce nondeterministic dashboards — pin the sort.
142        let base = NixpkgsBridge::new()
143            .with_names(vec!["zulu".into(), "alpha".into()])
144            .with_label("base");
145        let top = NixpkgsBridge::new()
146            .with_names(vec!["mike".into(), "charlie".into()])
147            .with_label("top");
148        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
149        let ns = overlay.names();
150        assert_eq!(ns, vec!["alpha", "charlie", "mike", "zulu"]);
151    }
152
153    #[test]
154    fn duplicate_names_across_sides_deduped() {
155        // "bash" in both → appears once. Regression guard: if
156        // `.contains` is dropped, dashboards list bash twice.
157        let base = NixpkgsBridge::new()
158            .with_names(vec!["bash".into(), "hello".into()])
159            .with_label("base");
160        let top = NixpkgsBridge::new()
161            .with_names(vec!["bash".into(), "zsh".into()])
162            .with_label("top");
163        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
164        let ns = overlay.names();
165        let bash_count = ns.iter().filter(|n| *n == "bash").count();
166        assert_eq!(bash_count, 1);
167        assert_eq!(ns.len(), 3);
168    }
169
170    #[test]
171    fn top_error_propagates() {
172        // If `top.get` returns Err, the overlay surfaces it
173        // immediately — it does NOT fall through to base.
174        let base = NixpkgsBridge::new()
175            .with_names(vec!["curl".into()])
176            .with_label("base");
177        let top = Erroring {
178            reason: "top exploded".into(),
179        };
180        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
181        let err = overlay.get("curl").unwrap_err();
182        assert_eq!(err.to_string(), "backend: top exploded");
183    }
184
185    #[test]
186    fn base_error_propagates_when_top_returns_none() {
187        // `top.get` returns None (name not in closed universe), so we
188        // fall through to base, which errors — that error must reach
189        // the caller.
190        let base = Erroring {
191            reason: "base exploded".into(),
192        };
193        let top = NixpkgsBridge::new()
194            .with_names(vec!["never".into()])
195            .with_label("top");
196        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
197        let err = overlay.get("not-in-top").unwrap_err();
198        assert_eq!(err.to_string(), "backend: base exploded");
199    }
200
201    #[test]
202    fn only_base_resolves_when_top_universe_empty() {
203        // Top is a closed universe with one name that won't match;
204        // "hello" falls through to base.
205        let base = NixpkgsBridge::new()
206            .with_names(vec!["hello".into()])
207            .with_label("base");
208        let top = NixpkgsBridge::new()
209            .with_names(vec!["nothing".into()])
210            .with_label("top");
211        let overlay = OverlayPackageSet::new(Box::new(base), Box::new(top));
212        let hello = overlay.get("hello").unwrap().unwrap();
213        assert_eq!(hello.name, "hello");
214        // Came from base, so pkg_set is None (base had no custom expr).
215        assert!(hello.bridge.unwrap().pkg_set.is_none());
216    }
217}