Skip to main content

gen_config/
lib.rs

1//! `gen-config` — typed operator-facing configuration for the gen
2//! engine. Implements [`shikumi::TieredConfig`]; every consumer
3//! resolves via `GenConfig::resolve_from_env("GEN_TIER")` at startup.
4//!
5//! The four typed slot groups (workspace / cache / render / target)
6//! cover every knob an operator can flip without recompiling. New
7//! knobs land here, not in CLI flags — flags are a view, config is
8//! the substrate.
9
10use indexmap::IndexMap;
11use serde::{Deserialize, Serialize};
12use shikumi::TieredConfig;
13
14/// Top-level config. Composes the four typed sub-slots.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct GenConfig {
17    pub workspace: WorkspaceConfig,
18    pub cache: CacheConfig,
19    pub render: RenderConfig,
20    pub target: TargetConfig,
21}
22
23impl Default for GenConfig {
24    fn default() -> Self {
25        Self::prescribed_default()
26    }
27}
28
29/// Workspace discovery + adapter routing.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct WorkspaceConfig {
32    /// Root path the engine operates against. Empty = CWD.
33    pub root: String,
34    /// `(filename → adapter)` table. Engine probes the root for each
35    /// file in declaration order and dispatches to the matching
36    /// adapter. New adapters extend the map; consumers never touch
37    /// the dispatch logic.
38    pub adapter_routing: IndexMap<String, String>,
39    /// Override the auto-detected adapter — `cargo` / `npm` / `bundler`
40    /// / `pip` / `gomod` / `helm` / `auto` (default).
41    pub force_adapter: Option<String>,
42}
43
44/// Substituter / cache settings. Engine consults this before deciding
45/// to evaluate a derivation; substituter hits short-circuit the rebuild.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47pub struct CacheConfig {
48    /// Substituter URLs (binary-cache backends). The engine consults
49    /// each in order; first hit wins. Empty list disables substituter
50    /// lookup (always-build mode, useful for CI's first canary).
51    pub substituters: Vec<String>,
52    /// Public keys trusted for substituter signatures.
53    pub trusted_public_keys: Vec<String>,
54    /// Build the package even when a substituter hit is found. Useful
55    /// for cache-population runs.
56    pub always_build: bool,
57}
58
59/// Render-shape settings. Controls whether the engine emits per-crate
60/// derivations (crate2nix shape, default for incremental local dev) or
61/// per-tree derivations (crane shape, default for CI bulk builds).
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct RenderConfig {
64    pub mode: RenderMode,
65    /// Optional output path for the rendered Nix expression. Empty =
66    /// stdout (most operator workflows).
67    pub output_path: String,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum RenderMode {
73    /// Per-crate derivation. Best for incremental local dev — touch one
74    /// crate, rebuild only that crate's closure.
75    PerCrate,
76    /// Per-tree derivation. Best for CI's bulk builds — fewer
77    /// derivations to evaluate, no per-edge fan-out cost.
78    PerTree,
79}
80
81/// Concrete target the engine evaluates predicates against.
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83pub struct TargetConfig {
84    pub os: String,
85    pub cpu: String,
86    pub libc: Option<String>,
87}
88
89impl TieredConfig for GenConfig {
90    fn bare() -> Self {
91        Self {
92            workspace: WorkspaceConfig {
93                root: String::new(),
94                adapter_routing: IndexMap::new(),
95                force_adapter: None,
96            },
97            cache: CacheConfig {
98                substituters: Vec::new(),
99                trusted_public_keys: Vec::new(),
100                always_build: false,
101            },
102            render: RenderConfig {
103                mode: RenderMode::PerCrate,
104                output_path: String::new(),
105            },
106            target: TargetConfig {
107                os: String::new(),
108                cpu: String::new(),
109                libc: None,
110            },
111        }
112    }
113
114    fn prescribed_default() -> Self {
115        let mut adapter_routing = IndexMap::new();
116        adapter_routing.insert("Cargo.toml".to_string(), "cargo".to_string());
117        adapter_routing.insert("package.json".to_string(), "npm".to_string());
118        adapter_routing.insert("Gemfile".to_string(), "bundler".to_string());
119        adapter_routing.insert("pyproject.toml".to_string(), "pip".to_string());
120        adapter_routing.insert("go.mod".to_string(), "gomod".to_string());
121        adapter_routing.insert("Chart.yaml".to_string(), "helm".to_string());
122
123        Self {
124            workspace: WorkspaceConfig {
125                root: ".".to_string(),
126                adapter_routing,
127                force_adapter: None,
128            },
129            cache: CacheConfig {
130                substituters: vec![
131                    "https://cache.nixos.org".to_string(),
132                    "http://cache.plo.quero.cloud/nexus".to_string(),
133                ],
134                trusted_public_keys: vec![
135                    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=".to_string(),
136                ],
137                always_build: false,
138            },
139            render: RenderConfig {
140                mode: RenderMode::PerCrate,
141                output_path: String::new(),
142            },
143            target: TargetConfig {
144                os: host_os(),
145                cpu: host_cpu(),
146                libc: host_libc(),
147            },
148        }
149    }
150
151    fn discovered() -> Self {
152        let mut c = Self::bare();
153        c.target = TargetConfig {
154            os: host_os(),
155            cpu: host_cpu(),
156            libc: host_libc(),
157        };
158        if let Ok(cwd) = std::env::current_dir() {
159            c.workspace.root = cwd.display().to_string();
160        }
161        c
162    }
163}
164
165fn host_os() -> String {
166    #[cfg(target_os = "linux")]
167    {
168        "linux".to_string()
169    }
170    #[cfg(target_os = "macos")]
171    {
172        "macos".to_string()
173    }
174    #[cfg(target_os = "windows")]
175    {
176        "windows".to_string()
177    }
178    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
179    {
180        "unknown".to_string()
181    }
182}
183
184fn host_cpu() -> String {
185    #[cfg(target_arch = "x86_64")]
186    {
187        "x86_64".to_string()
188    }
189    #[cfg(target_arch = "aarch64")]
190    {
191        "aarch64".to_string()
192    }
193    #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
194    {
195        "unknown".to_string()
196    }
197}
198
199fn host_libc() -> Option<String> {
200    #[cfg(all(target_os = "linux", target_env = "gnu"))]
201    {
202        Some("gnu".to_string())
203    }
204    #[cfg(all(target_os = "linux", target_env = "musl"))]
205    {
206        Some("musl".to_string())
207    }
208    #[cfg(not(target_os = "linux"))]
209    {
210        None
211    }
212    #[cfg(all(target_os = "linux", not(any(target_env = "gnu", target_env = "musl"))))]
213    {
214        None
215    }
216}
217
218/// Convenience: build a [`gen_types::Target`] from the config's
219/// [`TargetConfig`]. Engines call this to seed predicate evaluation.
220#[must_use]
221pub fn target_from_config(t: &TargetConfig) -> gen_types::Target {
222    gen_types::Target {
223        os: t.os.clone(),
224        cpu: t.cpu.clone(),
225        libc: t.libc.clone(),
226        engines: indexmap::IndexMap::new(),
227        python_env_markers: indexmap::IndexMap::new(),
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn bare_is_empty() {
237        let c = GenConfig::bare();
238        assert!(c.workspace.root.is_empty());
239        assert!(c.workspace.adapter_routing.is_empty());
240        assert!(c.cache.substituters.is_empty());
241        assert!(c.target.os.is_empty());
242    }
243
244    #[test]
245    fn prescribed_default_has_fleet_substituters() {
246        let c = GenConfig::prescribed_default();
247        assert!(c.cache.substituters.iter().any(|s| s.contains("cache.nixos.org")));
248        assert!(c.cache.substituters.iter().any(|s| s.contains("plo.quero.cloud")));
249    }
250
251    #[test]
252    fn prescribed_default_routes_cargo() {
253        let c = GenConfig::prescribed_default();
254        assert_eq!(
255            c.workspace.adapter_routing.get("Cargo.toml").map(String::as_str),
256            Some("cargo")
257        );
258    }
259
260    #[test]
261    fn discovered_seeds_target_from_host() {
262        let c = GenConfig::discovered();
263        assert!(matches!(c.target.os.as_str(), "linux" | "macos" | "windows" | "unknown"));
264        assert!(matches!(c.target.cpu.as_str(), "x86_64" | "aarch64" | "unknown"));
265    }
266
267    #[test]
268    fn round_trips_through_serde_yaml() {
269        let c = GenConfig::prescribed_default();
270        let y = serde_yaml::to_string(&c).unwrap();
271        let parsed: GenConfig = serde_yaml::from_str(&y).unwrap();
272        assert_eq!(c, parsed);
273    }
274
275    #[test]
276    fn target_from_config_round_trips() {
277        let c = GenConfig::prescribed_default();
278        let t = target_from_config(&c.target);
279        assert_eq!(t.os, c.target.os);
280        assert_eq!(t.cpu, c.target.cpu);
281    }
282}