1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::discover::{self, Platform};
7
8pub const CONFIG_FILE: &str = "config.pkl";
9pub const CONFIG_TOML_COMPAT_FILE: &str = "config.toml";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum LocalConfigFormat {
13 Pkl,
14 TomlCompat,
15}
16
17struct LocalConfigSource {
18 path: PathBuf,
19 format: LocalConfigFormat,
20}
21
22pub struct Config {
24 pub repo: PathBuf,
26 pub hostname: String,
28 pub nix_packages_file: PathBuf,
30 pub homebrew_file: PathBuf,
32 pub module_files: Vec<(String, PathBuf)>,
34 pub prefer_nix_on_equal: bool,
36 pub platform: Platform,
38}
39
40#[derive(Deserialize, Serialize, Default)]
42struct FileConfig {
43 repo_path: Option<String>,
44 hostname: Option<String>,
45 prefer_nix_on_equal: Option<bool>,
46 identity: Option<IdentityConfig>,
47}
48
49#[derive(Deserialize, Serialize, Default, Clone)]
51pub struct IdentityConfig {
52 pub git: Option<IdentityGitConfig>,
54 pub ssh: Option<IdentitySshConfig>,
56}
57
58#[derive(Deserialize, Serialize, Default, Clone)]
60pub struct IdentityGitConfig {
61 pub name: Option<String>,
62 pub email: Option<String>,
63}
64
65#[derive(Deserialize, Serialize, Default, Clone)]
67pub struct IdentitySshConfig {
68 pub labels: Option<Vec<String>>,
69}
70
71impl Config {
72 pub fn resolve(cli_repo: Option<PathBuf>, cli_hostname: Option<String>) -> Result<Self> {
74 tracing::debug!(cli_repo = ?cli_repo, cli_hostname = ?cli_hostname, "resolving config");
75 let file_config = load_file_config().unwrap_or_default();
76 let platform = discover::detect_platform();
77
78 let not_found_msg = match platform {
79 Platform::Darwin => {
80 "Could not find nix-darwin repo. Run `nex init`, set NEX_REPO, \
81 or create ~/.config/nex/config.pkl with repo_path."
82 }
83 Platform::Linux => {
84 "Could not find NixOS config repo. Run `nex init`, set NEX_REPO, \
85 or create ~/.config/nex/config.pkl with repo_path."
86 }
87 };
88
89 let repo = cli_repo
90 .or_else(|| file_config.repo_path.map(PathBuf::from))
91 .or_else(|| discover::find_repo().ok())
92 .context(not_found_msg)?;
93
94 tracing::debug!(repo = %repo.display(), "config repo resolved");
95
96 let hostname = cli_hostname
97 .or(file_config.hostname)
98 .or_else(|| discover::hostname().ok())
99 .context("Could not detect hostname. Set NEX_HOSTNAME.")?;
100
101 tracing::debug!(%hostname, "hostname resolved");
102
103 if hostname.is_empty()
105 || hostname.starts_with('-')
106 || hostname.ends_with('-')
107 || !hostname
108 .chars()
109 .all(|c| c.is_ascii_alphanumeric() || c == '-')
110 {
111 anyhow::bail!(
112 "invalid hostname \"{hostname}\": must be alphanumeric with hyphens, \
113 no leading/trailing hyphen"
114 );
115 }
116
117 let scaffolded = repo.join("nix/modules/home").exists();
119
120 let nix_packages_file = if scaffolded {
121 repo.join("nix/modules/home/base.nix")
122 } else if repo.join("home.nix").exists() {
123 repo.join("home.nix")
124 } else {
125 repo.join("nix/modules/home/base.nix") };
127
128 let homebrew_file = match platform {
129 Platform::Darwin => repo.join("nix/modules/darwin/homebrew.nix"),
130 Platform::Linux => {
131 if scaffolded {
132 repo.join("nix/modules/nixos/packages.nix")
133 } else {
134 repo.join("configuration.nix")
135 }
136 }
137 };
138
139 let mut module_files = Vec::new();
141 let home_modules_dir = repo.join("nix/modules/home");
142 if home_modules_dir.is_dir() {
143 if let Ok(entries) = std::fs::read_dir(&home_modules_dir) {
144 for entry in entries.flatten() {
145 let path = entry.path();
146 if path.extension().and_then(|e| e.to_str()) != Some("nix") {
147 continue;
148 }
149 let stem = path
150 .file_stem()
151 .and_then(|s| s.to_str())
152 .unwrap_or("")
153 .to_string();
154 if stem == "base" || stem == "default" {
156 continue;
157 }
158 tracing::debug!(module = %stem, path = %path.display(), "discovered module");
159 module_files.push((stem, path));
160 }
161 }
162 module_files.sort_by(|a, b| a.0.cmp(&b.0));
163 }
164
165 let prefer_nix_on_equal = file_config.prefer_nix_on_equal.unwrap_or(false);
166
167 Ok(Config {
168 repo,
169 hostname,
170 nix_packages_file,
171 homebrew_file,
172 module_files,
173 prefer_nix_on_equal,
174 platform,
175 })
176 }
177
178 pub fn all_nix_package_files(&self) -> Vec<&PathBuf> {
180 let mut files = vec![&self.nix_packages_file];
181 for (_, path) in &self.module_files {
182 files.push(path);
183 }
184 files
185 }
186}
187
188pub fn set_preference(key: &str, value: &str) -> Result<()> {
190 tracing::debug!(%key, %value, "setting preference");
191 let path = writable_config_path()?;
192 let content = read_config_value_for_write(&path)?;
193
194 let mut table = match content {
195 toml::Value::Table(table) => table,
196 _ => bail!("local Nex config root must be a table"),
197 };
198
199 let parsed_value: toml::Value =
200 toml::from_str::<toml::map::Map<String, toml::Value>>(&format!("v = {value}"))
201 .with_context(|| format!("invalid TOML value: {value}"))
202 .and_then(|t| {
203 t.into_iter()
204 .next()
205 .map(|(_, v)| v)
206 .context("empty TOML parse result")
207 })?;
208
209 table.insert(key.to_string(), parsed_value);
210 write_config_value(&path, &toml::Value::Table(table))
211}
212
213pub fn config_dir() -> Result<PathBuf> {
215 let home = dirs::home_dir().context("no home directory")?;
216 Ok(home.join(".config/nex"))
217}
218
219pub fn canonical_config_path() -> Result<PathBuf> {
220 Ok(config_dir()?.join(CONFIG_FILE))
221}
222
223pub fn toml_compat_config_path() -> Result<PathBuf> {
224 Ok(config_dir()?.join(CONFIG_TOML_COMPAT_FILE))
225}
226
227pub fn load_identity_config() -> Result<IdentityConfig> {
229 let fc = load_file_config().unwrap_or_default();
230 Ok(fc.identity.unwrap_or_default())
231}
232
233pub fn set_nested_preference(dotted_key: &str, value: toml::Value) -> Result<()> {
236 let path = writable_config_path()?;
237 let mut root = read_config_value_for_write(&path)?;
238
239 let parts: Vec<&str> = dotted_key.split('.').collect();
240 let mut current = &mut root;
241 for part in &parts[..parts.len() - 1] {
242 if !current.is_table() {
243 bail!("config key '{part}' is not a table");
244 }
245 current = current
246 .as_table_mut()
247 .expect("checked above")
248 .entry(part.to_string())
249 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
250 }
251
252 let leaf = parts.last().context("empty key")?;
253 current
254 .as_table_mut()
255 .context("leaf parent is not a table")?
256 .insert(leaf.to_string(), value);
257
258 write_config_value(&path, &root)
259}
260
261pub fn append_to_list(dotted_key: &str, item: &str) -> Result<()> {
264 let path = writable_config_path()?;
265 let mut root = read_config_value_for_write(&path)?;
266
267 let parts: Vec<&str> = dotted_key.split('.').collect();
268 let mut current = &mut root;
269 for part in &parts[..parts.len() - 1] {
270 current = current
271 .as_table_mut()
272 .context("not a table")?
273 .entry(part.to_string())
274 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
275 }
276
277 let leaf = parts.last().context("empty key")?;
278 let table = current
279 .as_table_mut()
280 .context("leaf parent is not a table")?;
281 let arr = table
282 .entry(leaf.to_string())
283 .or_insert_with(|| toml::Value::Array(Vec::new()));
284
285 let arr = arr.as_array_mut().context("config key is not an array")?;
286 let item_val = toml::Value::String(item.to_string());
287 if !arr.contains(&item_val) {
288 arr.push(item_val);
289 }
290
291 write_config_value(&path, &root)
292}
293
294pub fn write_initial_config(repo_path: &Path, hostname: &str) -> Result<PathBuf> {
295 let path = canonical_config_path()?;
296 let mut table = toml::map::Map::new();
297 table.insert(
298 "repo_path".to_string(),
299 toml::Value::String(repo_path.display().to_string()),
300 );
301 table.insert(
302 "hostname".to_string(),
303 toml::Value::String(hostname.to_string()),
304 );
305 write_config_value(&path, &toml::Value::Table(table))?;
306 Ok(path)
307}
308
309pub fn migrate_to_pkl(keep_toml: bool) -> Result<PathBuf> {
310 let source = resolve_config_source()?;
311 let value = match source {
312 Some(LocalConfigSource {
313 path,
314 format: LocalConfigFormat::Pkl,
315 }) => read_config_value_for_write(&path)?,
316 Some(LocalConfigSource {
317 path,
318 format: LocalConfigFormat::TomlCompat,
319 }) => read_config_value_for_write(&path)?,
320 None => toml::Value::Table(toml::map::Map::new()),
321 };
322
323 let canonical = canonical_config_path()?;
324 write_config_value(&canonical, &value)?;
325
326 if keep_toml {
327 let compat = toml_compat_config_path()?;
328 let rendered = toml::to_string_pretty(&value).context("serializing compatibility TOML")?;
329 crate::edit::atomic_write_bytes(&compat, rendered.as_bytes())
330 .with_context(|| format!("writing {}", compat.display()))?;
331 }
332
333 Ok(canonical)
334}
335
336pub fn export_config_toml() -> Result<String> {
337 let config = load_file_config()?;
338 let value = toml::Value::try_from(config).context("converting config to TOML value")?;
339 toml::to_string_pretty(&value).context("serializing config TOML")
340}
341
342fn load_file_config() -> Result<FileConfig> {
343 match resolve_config_source()? {
344 Some(source) => match source.format {
345 LocalConfigFormat::Pkl => {
346 tracing::debug!(path = %source.path.display(), "loaded canonical Pkl config file");
347 crate::document::load_document::<FileConfig>(&source.path, "local Nex config")
348 .map(|loaded| loaded.value)
349 }
350 LocalConfigFormat::TomlCompat => {
351 tracing::debug!(path = %source.path.display(), "loaded compatibility TOML config file");
352 let content = std::fs::read_to_string(&source.path)
353 .with_context(|| format!("reading {}", source.path.display()))?;
354 toml::from_str(&content)
355 .with_context(|| format!("invalid config in {}", source.path.display()))
356 }
357 },
358 None => Ok(FileConfig::default()),
359 }
360}
361
362fn resolve_config_source() -> Result<Option<LocalConfigSource>> {
363 let canonical = canonical_config_path()?;
364 if canonical.exists() {
365 return Ok(Some(LocalConfigSource {
366 path: canonical,
367 format: LocalConfigFormat::Pkl,
368 }));
369 }
370
371 let compat = toml_compat_config_path()?;
372 if compat.exists() {
373 return Ok(Some(LocalConfigSource {
374 path: compat,
375 format: LocalConfigFormat::TomlCompat,
376 }));
377 }
378
379 if let Some(platform_dir) = dirs::config_dir() {
380 let legacy = platform_dir.join(format!("nex/{CONFIG_TOML_COMPAT_FILE}"));
381 if legacy.exists() {
382 return Ok(Some(LocalConfigSource {
383 path: legacy,
384 format: LocalConfigFormat::TomlCompat,
385 }));
386 }
387 }
388
389 Ok(None)
390}
391
392fn writable_config_path() -> Result<PathBuf> {
393 let canonical = canonical_config_path()?;
394 if canonical.exists() {
395 return Ok(canonical);
396 }
397 let compat = toml_compat_config_path()?;
398 if compat.exists() {
399 return Ok(compat);
400 }
401 Ok(canonical)
402}
403
404fn read_config_value_for_write(path: &Path) -> Result<toml::Value> {
405 if !path.exists() {
406 return Ok(toml::Value::Table(toml::map::Map::new()));
407 }
408 match config_format_for_path(path)? {
409 LocalConfigFormat::Pkl => {
410 let config =
411 crate::document::load_document::<FileConfig>(path, "local Nex config")?.value;
412 toml::Value::try_from(config).context("converting config to mutable value")
413 }
414 LocalConfigFormat::TomlCompat => {
415 let content = std::fs::read_to_string(path)
416 .with_context(|| format!("reading {}", path.display()))?;
417 if content.trim().is_empty() {
418 Ok(toml::Value::Table(toml::map::Map::new()))
419 } else {
420 toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
421 }
422 }
423 }
424}
425
426fn write_config_value(path: &Path, value: &toml::Value) -> Result<()> {
427 let serialized = serialize_config_value(value, config_format_for_path(path)?)?;
428 std::fs::create_dir_all(config_dir()?)?;
429 crate::edit::atomic_write_bytes(path, serialized.as_bytes())
430 .with_context(|| format!("writing {}", path.display()))
431}
432
433fn config_format_for_path(path: &Path) -> Result<LocalConfigFormat> {
434 match path.extension().and_then(|ext| ext.to_str()) {
435 Some("pkl") => Ok(LocalConfigFormat::Pkl),
436 Some("toml") => Ok(LocalConfigFormat::TomlCompat),
437 Some(ext) => bail!("unsupported config extension .{ext}; canonical Nex config uses .pkl"),
438 None => bail!("config path must have an extension"),
439 }
440}
441
442fn serialize_config_value(value: &toml::Value, format: LocalConfigFormat) -> Result<String> {
443 match format {
444 LocalConfigFormat::Pkl => serialize_pkl_document(value),
445 LocalConfigFormat::TomlCompat => {
446 toml::to_string_pretty(value).context("serializing config TOML")
447 }
448 }
449}
450
451fn serialize_pkl_document(value: &toml::Value) -> Result<String> {
452 let mut out =
453 String::from("// Generated by nex. Edit config.pkl as the canonical local config.\n");
454 let table = value.as_table().context("config root must be a table")?;
455 for (key, value) in table {
456 write_pkl_entry(&mut out, key, value, 0)?;
457 }
458 Ok(out)
459}
460
461fn write_pkl_entry(out: &mut String, key: &str, value: &toml::Value, indent: usize) -> Result<()> {
462 let padding = " ".repeat(indent);
463 match value {
464 toml::Value::Table(table) => {
465 out.push_str(&format!("{padding}{key} {{\n"));
466 for (child_key, child_value) in table {
467 write_pkl_entry(out, child_key, child_value, indent + 1)?;
468 }
469 out.push_str(&format!("{padding}}}\n"));
470 }
471 _ => {
472 out.push_str(&format!(
473 "{padding}{key} = {}\n",
474 pkl_literal(value).with_context(|| format!("serializing config key {key}"))?
475 ));
476 }
477 }
478 Ok(())
479}
480
481fn pkl_literal(value: &toml::Value) -> Result<String> {
482 match value {
483 toml::Value::String(value) => Ok(format!("{value:?}")),
484 toml::Value::Boolean(value) => Ok(value.to_string()),
485 toml::Value::Integer(value) => Ok(value.to_string()),
486 toml::Value::Float(value) => Ok(value.to_string()),
487 toml::Value::Array(values) => {
488 let rendered = values
489 .iter()
490 .map(pkl_literal)
491 .collect::<Result<Vec<_>>>()?
492 .join(", ");
493 Ok(format!("List({rendered})"))
494 }
495 toml::Value::Datetime(value) => Ok(format!("{:?}", value.to_string())),
496 toml::Value::Table(_) => bail!("nested table should be serialized as a Pkl block"),
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use std::fs;
504
505 #[test]
506 fn serializes_generated_pkl_config() {
507 let mut table = toml::map::Map::new();
508 table.insert(
509 "repo_path".to_string(),
510 toml::Value::String("/tmp/repo".to_string()),
511 );
512 table.insert(
513 "hostname".to_string(),
514 toml::Value::String("test-host".to_string()),
515 );
516 table.insert(
517 "prefer_nix_on_equal".to_string(),
518 toml::Value::Boolean(true),
519 );
520
521 let rendered = serialize_pkl_document(&toml::Value::Table(table)).unwrap();
522 assert!(rendered.contains("repo_path = \"/tmp/repo\""));
523 assert!(rendered.contains("hostname = \"test-host\""));
524 assert!(rendered.contains("prefer_nix_on_equal = true"));
525 }
526
527 #[test]
528 fn resolves_pkl_before_toml() {
529 let dir = tempfile::tempdir().unwrap();
530 fs::write(dir.path().join(CONFIG_FILE), "repo_path = \"/pkl\"\n").unwrap();
531 fs::write(
532 dir.path().join(CONFIG_TOML_COMPAT_FILE),
533 "repo_path = \"/toml\"\n",
534 )
535 .unwrap();
536
537 let pkl = dir.path().join(CONFIG_FILE);
538 let toml = dir.path().join(CONFIG_TOML_COMPAT_FILE);
539 assert!(pkl.exists());
540 assert!(toml.exists());
541 assert_eq!(
542 config_format_for_path(&pkl).unwrap(),
543 LocalConfigFormat::Pkl
544 );
545 assert_eq!(
546 config_format_for_path(&toml).unwrap(),
547 LocalConfigFormat::TomlCompat
548 );
549 }
550 #[test]
551 fn migrate_preserves_toml_export_shape() {
552 let mut table = toml::map::Map::new();
553 table.insert(
554 "repo_path".to_string(),
555 toml::Value::String("/tmp/repo".to_string()),
556 );
557 table.insert(
558 "hostname".to_string(),
559 toml::Value::String("test-host".to_string()),
560 );
561 let value = toml::Value::Table(table);
562 let pkl = serialize_config_value(&value, LocalConfigFormat::Pkl).unwrap();
563 let toml = serialize_config_value(&value, LocalConfigFormat::TomlCompat).unwrap();
564
565 assert!(pkl.contains("repo_path = \"/tmp/repo\""));
566 assert!(toml.contains("repo_path = \"/tmp/repo\""));
567 assert!(toml::from_str::<toml::Value>(&toml).is_ok());
568 }
569}