Skip to main content

tatara_pkgs/
generator.rs

1//! `NixpkgsMirror` — the tend-driven generator.
2//!
3//! Walks a `PackageSet` and emits one tatara-lisp file per package, ready to
4//! be `tatara-lispc`-compiled back to a typed `Derivation` that realizes to
5//! the same `/nix/store/...` path as the source package set.
6
7use thiserror::Error;
8
9use tatara_nix::{Artifact, MultiSynthesizer};
10
11use crate::set::{PackageSet, PackageSetError};
12
13#[derive(Debug, Error)]
14pub enum NixpkgsMirrorError {
15    #[error("package set: {0}")]
16    Set(#[from] PackageSetError),
17
18    #[error("package missing while enumerating: {0}")]
19    EnumerationGap(String),
20}
21
22/// Mirror generator: iterates `source.names()`, calls `source.get(name)`,
23/// renders each as a `(defderivation … :bridge …)` tatara-lisp form.
24///
25/// Stateless — call `.generate_all(&source)` per sync run. `tend`'s watcher
26/// invokes this when a pinned nixpkgs rev bumps.
27pub struct NixpkgsMirror {
28    /// Directory prefix for emitted files (relative or absolute). Each
29    /// generated `Artifact::path` is `<prefix>/<name>.tl`. `tend` drops
30    /// artifacts into the target repo directly.
31    pub out_prefix: String,
32    /// Top-of-file header to include in each emitted `.tl` file.
33    pub header: String,
34}
35
36impl Default for NixpkgsMirror {
37    fn default() -> Self {
38        Self {
39            out_prefix: "pkgs".into(),
40            header: "; AUTO-GENERATED by tatara-pkgs — do not hand-edit.\n\
41                     ; Source of truth: the upstream PackageSet pin in tend config.\n\n"
42                .into(),
43        }
44    }
45}
46
47impl NixpkgsMirror {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
53        self.out_prefix = prefix.into();
54        self
55    }
56
57    /// Render one package to a single tatara-lisp artifact.
58    pub fn render_one(&self, name: &str, pkg_set_expr: Option<&str>) -> Artifact {
59        let path = format!("{}/{}.tl", self.out_prefix, name_to_file(name));
60        let pkg_set_line = match pkg_set_expr {
61            Some(expr) => format!(
62                "  :bridge (:attr-path \"{}\" :pkg-set {})\n",
63                name,
64                nix_string_literal(expr)
65            ),
66            None => format!("  :bridge (:attr-path \"{}\")\n", name),
67        };
68        let content = format!(
69            "{}(defderivation\n  :name \"{}\"\n{})\n",
70            self.header, name, pkg_set_line
71        );
72        Artifact::new(path, content)
73    }
74}
75
76fn nix_string_literal(s: &str) -> String {
77    // Plain strings: "..." with embedded quotes escaped. Multi-line or
78    // quote-heavy exprs get the Lisp string; that's the only quoting surface
79    // tatara-lisp reader handles today.
80    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
81}
82
83fn name_to_file(name: &str) -> String {
84    // `python3Packages.requests` → `python3Packages/requests`; keeps the tree
85    // structure mirror-accurate and avoids filename collisions.
86    name.replace('.', "/")
87}
88
89impl MultiSynthesizer for NixpkgsMirror {
90    type Input = dyn PackageSet;
91
92    fn generate_all(&self, input: &Self::Input) -> Vec<Artifact> {
93        let mut out = Vec::new();
94        for name in input.names() {
95            let d = match input.get(&name) {
96                Ok(Some(d)) => d,
97                _ => continue, // skip gaps — caller can re-check source
98            };
99            let pkg_set_expr = d.bridge.as_ref().and_then(|b| b.pkg_set.clone());
100            out.push(self.render_one(&name, pkg_set_expr.as_deref()));
101        }
102        out
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::NixpkgsBridge;
110
111    #[test]
112    fn mirror_emits_one_artifact_per_package() {
113        let src = NixpkgsBridge::new()
114            .with_names(vec!["hello".into(), "bash".into()])
115            .with_label("nixpkgs");
116        let mirror = NixpkgsMirror::new().with_prefix("pkgs");
117        let arts = mirror.generate_all(&src);
118        assert_eq!(arts.len(), 2);
119        let paths: Vec<_> = arts.iter().map(|a| a.path.as_str()).collect();
120        assert!(paths.contains(&"pkgs/hello.tl"));
121        assert!(paths.contains(&"pkgs/bash.tl"));
122    }
123
124    #[test]
125    fn emitted_tl_reads_back_via_tatara_lisp() {
126        // Round-trip: render → tatara-lisp::read parses without error.
127        let mirror = NixpkgsMirror::new();
128        let art = mirror.render_one("hello", None);
129        let forms = tatara_lisp::read(&art.content).unwrap();
130        // The `(defderivation …)` form is the last top-level item after any
131        // header comments (reader drops comments).
132        let last = forms.last().unwrap();
133        let list = last.as_list().unwrap();
134        assert_eq!(list[0].as_symbol(), Some("defderivation"));
135    }
136
137    #[test]
138    fn dotted_names_map_to_nested_directory_paths() {
139        let mirror = NixpkgsMirror::new().with_prefix("pkgs");
140        let art = mirror.render_one("python3Packages.requests", None);
141        assert_eq!(art.path, "pkgs/python3Packages/requests.tl");
142    }
143
144    #[test]
145    fn custom_pkg_set_is_quoted_into_emitted_form() {
146        let mirror = NixpkgsMirror::new();
147        let art = mirror.render_one("mypkg", Some("import ./release.nix {}"));
148        assert!(art.content.contains(":pkg-set \"import ./release.nix {}\""));
149    }
150}