tatara-pkgs 0.2.166

Package set abstraction + nixpkgs bridge + tend-driven tatara-lisp mirror — the tatara equivalent of nixpkgs
//! `NixpkgsMirror` — the tend-driven generator.
//!
//! Walks a `PackageSet` and emits one tatara-lisp file per package, ready to
//! be `tatara-lispc`-compiled back to a typed `Derivation` that realizes to
//! the same `/nix/store/...` path as the source package set.

use thiserror::Error;

use tatara_nix::{Artifact, MultiSynthesizer};

use crate::set::{PackageSet, PackageSetError};

#[derive(Debug, Error)]
pub enum NixpkgsMirrorError {
    #[error("package set: {0}")]
    Set(#[from] PackageSetError),

    #[error("package missing while enumerating: {0}")]
    EnumerationGap(String),
}

/// Mirror generator: iterates `source.names()`, calls `source.get(name)`,
/// renders each as a `(defderivation … :bridge …)` tatara-lisp form.
///
/// Stateless — call `.generate_all(&source)` per sync run. `tend`'s watcher
/// invokes this when a pinned nixpkgs rev bumps.
pub struct NixpkgsMirror {
    /// Directory prefix for emitted files (relative or absolute). Each
    /// generated `Artifact::path` is `<prefix>/<name>.tl`. `tend` drops
    /// artifacts into the target repo directly.
    pub out_prefix: String,
    /// Top-of-file header to include in each emitted `.tl` file.
    pub header: String,
}

impl Default for NixpkgsMirror {
    fn default() -> Self {
        Self {
            out_prefix: "pkgs".into(),
            header: "; AUTO-GENERATED by tatara-pkgs — do not hand-edit.\n\
                     ; Source of truth: the upstream PackageSet pin in tend config.\n\n"
                .into(),
        }
    }
}

impl NixpkgsMirror {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.out_prefix = prefix.into();
        self
    }

    /// Render one package to a single tatara-lisp artifact.
    pub fn render_one(&self, name: &str, pkg_set_expr: Option<&str>) -> Artifact {
        let path = format!("{}/{}.tl", self.out_prefix, name_to_file(name));
        let pkg_set_line = match pkg_set_expr {
            Some(expr) => format!(
                "  :bridge (:attr-path \"{}\" :pkg-set {})\n",
                name,
                nix_string_literal(expr)
            ),
            None => format!("  :bridge (:attr-path \"{}\")\n", name),
        };
        let content = format!(
            "{}(defderivation\n  :name \"{}\"\n{})\n",
            self.header, name, pkg_set_line
        );
        Artifact::new(path, content)
    }
}

fn nix_string_literal(s: &str) -> String {
    // Plain strings: "..." with embedded quotes escaped. Multi-line or
    // quote-heavy exprs get the Lisp string; that's the only quoting surface
    // tatara-lisp reader handles today.
    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}

fn name_to_file(name: &str) -> String {
    // `python3Packages.requests` → `python3Packages/requests`; keeps the tree
    // structure mirror-accurate and avoids filename collisions.
    name.replace('.', "/")
}

impl MultiSynthesizer for NixpkgsMirror {
    type Input = dyn PackageSet;

    fn generate_all(&self, input: &Self::Input) -> Vec<Artifact> {
        let mut out = Vec::new();
        for name in input.names() {
            let d = match input.get(&name) {
                Ok(Some(d)) => d,
                _ => continue, // skip gaps — caller can re-check source
            };
            let pkg_set_expr = d.bridge.as_ref().and_then(|b| b.pkg_set.clone());
            out.push(self.render_one(&name, pkg_set_expr.as_deref()));
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::NixpkgsBridge;

    #[test]
    fn mirror_emits_one_artifact_per_package() {
        let src = NixpkgsBridge::new()
            .with_names(vec!["hello".into(), "bash".into()])
            .with_label("nixpkgs");
        let mirror = NixpkgsMirror::new().with_prefix("pkgs");
        let arts = mirror.generate_all(&src);
        assert_eq!(arts.len(), 2);
        let paths: Vec<_> = arts.iter().map(|a| a.path.as_str()).collect();
        assert!(paths.contains(&"pkgs/hello.tl"));
        assert!(paths.contains(&"pkgs/bash.tl"));
    }

    #[test]
    fn emitted_tl_reads_back_via_tatara_lisp() {
        // Round-trip: render → tatara-lisp::read parses without error.
        let mirror = NixpkgsMirror::new();
        let art = mirror.render_one("hello", None);
        let forms = tatara_lisp::read(&art.content).unwrap();
        // The `(defderivation …)` form is the last top-level item after any
        // header comments (reader drops comments).
        let last = forms.last().unwrap();
        let list = last.as_list().unwrap();
        assert_eq!(list[0].as_symbol(), Some("defderivation"));
    }

    #[test]
    fn dotted_names_map_to_nested_directory_paths() {
        let mirror = NixpkgsMirror::new().with_prefix("pkgs");
        let art = mirror.render_one("python3Packages.requests", None);
        assert_eq!(art.path, "pkgs/python3Packages/requests.tl");
    }

    #[test]
    fn custom_pkg_set_is_quoted_into_emitted_form() {
        let mirror = NixpkgsMirror::new();
        let art = mirror.render_one("mypkg", Some("import ./release.nix {}"));
        assert!(art.content.contains(":pkg-set \"import ./release.nix {}\""));
    }
}