Skip to main content

nex_pkg/
config.rs

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
22/// Resolved configuration for a nex session.
23pub struct Config {
24    /// Path to the nix config repo root (nix-darwin or NixOS).
25    pub repo: PathBuf,
26    /// Hostname for system rebuild.
27    pub hostname: String,
28    /// Path to the primary nix packages file (relative to repo).
29    pub nix_packages_file: PathBuf,
30    /// Path to the homebrew nix file (relative to repo). None on Linux.
31    pub homebrew_file: PathBuf,
32    /// Additional nix module files with home.packages lists.
33    pub module_files: Vec<(String, PathBuf)>,
34    /// When true, auto-pick nix for equal-version conflicts without prompting.
35    pub prefer_nix_on_equal: bool,
36    /// The detected platform (Darwin or Linux).
37    pub platform: Platform,
38    /// Armory package registries for Omegon/package discovery.
39    pub registries: Vec<RegistryConfig>,
40}
41
42/// Optional config file at ~/.config/nex/config.pkl; config.toml is compatibility.
43#[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/// Identity-related configuration.
53#[derive(Deserialize, Serialize, Default, Clone)]
54pub struct IdentityConfig {
55    /// Git commit signing settings.
56    pub git: Option<IdentityGitConfig>,
57    /// SSH key label registry.
58    pub ssh: Option<IdentitySshConfig>,
59}
60
61/// Git signing config stored in nex config.
62#[derive(Deserialize, Serialize, Default, Clone)]
63pub struct IdentityGitConfig {
64    pub name: Option<String>,
65    pub email: Option<String>,
66}
67
68/// SSH key labels registered for this identity.
69#[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    /// Resolve configuration from CLI args, env vars, config file, and auto-discovery.
83    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        // Validate hostname: alphanumeric and hyphens only, no leading/trailing hyphen
114        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        // Standard file locations — detect scaffolded vs flat layout
128        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") // fallback
136        };
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        // Discover additional module files with home.packages
150        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                    // Skip the primary packages file and module entry points
165                    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    /// All nix files that contain home.packages lists (for duplicate checking).
194    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    /// Configured Homebrew formula target, if this profile enables one.
203    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    /// Configured Homebrew cask target, if this profile enables one.
211    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    /// True when any Homebrew provider target is enabled for this profile.
219    pub fn has_homebrew_provider(&self) -> bool {
220        self.homebrew_formula_target().is_some() || self.homebrew_cask_target().is_some()
221    }
222
223    /// Sources that auto-resolution may consider for this profile.
224    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
236/// Persist a key=value into the config file, preserving existing content.
237pub 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
261/// Canonical config directory: ~/.config/nex/
262pub 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
275/// Load just the identity portion of the config (does not require a nix repo).
276pub fn load_identity_config() -> Result<IdentityConfig> {
277    let fc = load_file_config().unwrap_or_default();
278    Ok(fc.identity.unwrap_or_default())
279}
280
281/// Set a nested config key using dotted notation (e.g. "identity.git.name").
282/// Creates intermediate tables as needed.
283pub 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
309/// Append a string to an array config value, creating it if it doesn't exist.
310/// Skips duplicates.
311pub 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}