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